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:
@@ -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"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
"agent-desk/internal/pkg/utils"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/repositories"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
registry *EngineRegistry
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -25,91 +26,46 @@ const (
|
||||
)
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
return NewServiceWithRegistry(NewDefaultEngineRegistry())
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req Request) (*Summary, error) {
|
||||
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
if err != nil {
|
||||
_, _ = writeWorkflowPrepareFailedRun(req, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Execute(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
})
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _ = writeWorkflowRun(req, workflow, workflowResult, err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, err := writeWorkflowRun(req, workflow, workflowResult, "")
|
||||
func NewServiceWithRegistry(registry *EngineRegistry) *Service {
|
||||
return &Service{registry: registry}
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context, req RunInput) (*RunResult, error) {
|
||||
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil
|
||||
return engine.Run(ctx, req)
|
||||
}
|
||||
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeRequest) (*Summary, error) {
|
||||
aiAgent, workflow, err := prepareWorkflowAgent(req.AIAgent)
|
||||
func (s *Service) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
|
||||
engine, err := s.registry.Resolve(resolveEngineCode(req.AIAgent.RuntimeMode))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.AIAgent = aiAgent
|
||||
if interrupt := repositories.ConversationInterruptRepository.GetByCheckPointID(sqls.DB(), req.CheckPointID); interrupt != nil {
|
||||
if strings.TrimSpace(interrupt.RequestData) == "" {
|
||||
if interrupt.WorkflowRunID > 0 || strings.HasPrefix(strings.TrimSpace(req.CheckPointID), "workflow:") {
|
||||
return nil, errorsx.InvalidParam("workflow checkpoint data is required")
|
||||
}
|
||||
} else {
|
||||
workflowResult, err := workflowexecutor.NewExecutor().Resume(ctx, workflowexecutor.Input{
|
||||
Definition: workflow.Definition,
|
||||
Conversation: req.Conversation,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, interrupt.RequestData, firstWorkflowResumeText(req.ResumeData))
|
||||
if err != nil {
|
||||
if workflowResult != nil {
|
||||
_, _ = writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, err.Error(), interrupt.WorkflowRunID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
workflowRunID, err := writeWorkflowRunWithExistingID(Request{
|
||||
Conversation: req.Conversation,
|
||||
UserMessage: req.UserMessage,
|
||||
AIAgent: req.AIAgent,
|
||||
AIConfig: req.AIConfig,
|
||||
}, workflow, workflowResult, "", interrupt.WorkflowRunID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWorkflowSummary(workflowResult, req.AIConfig.ModelName, workflow, workflowRunID), nil
|
||||
}
|
||||
}
|
||||
return nil, errorsx.InvalidParam("legacy checkpoint is not supported; please start a new workflow reply")
|
||||
return engine.Resume(ctx, req)
|
||||
}
|
||||
|
||||
func firstWorkflowResumeText(data map[string]string) string {
|
||||
for _, value := range data {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
// RunOfflineEvaluation executes an explicitly selected Engine against isolated
|
||||
// Debug inputs. It does not rely on the Agent's configured runtime mode, which
|
||||
// makes Workflow/Autonomous/Hybrid comparisons possible against one revision.
|
||||
func (s *Service) RunOfflineEvaluation(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) (OfflineEvaluationReport, error) {
|
||||
engine, err := s.registry.Resolve(strings.TrimSpace(engineCode))
|
||||
if err != nil {
|
||||
return OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode)}, err
|
||||
}
|
||||
return ""
|
||||
runner := NewOfflineEvaluationRunner(engine.Run)
|
||||
return runner.Run(ctx, engine.Code(), agent, config, cases), nil
|
||||
}
|
||||
|
||||
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64) *Summary {
|
||||
func resolveEngineCode(mode enums.AIAgentRuntimeMode) string {
|
||||
return strings.TrimSpace(string(mode))
|
||||
}
|
||||
|
||||
func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workflow resolvedWorkflow, workflowRunID int64, agentRunID int64) *Summary {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -131,6 +87,7 @@ func toWorkflowSummary(result *workflowexecutor.Result, modelName string, workfl
|
||||
WorkflowID: workflow.WorkflowID,
|
||||
WorkflowVersionID: workflow.VersionID,
|
||||
WorkflowRunID: workflowRunID,
|
||||
AgentRunID: agentRunID,
|
||||
WorkflowNodePath: append([]string(nil), result.NodePath...),
|
||||
TraceData: string(traceData),
|
||||
CheckPointID: result.CheckPointID,
|
||||
@@ -155,7 +112,7 @@ func toWorkflowInterruptSummaries(items []workflowexecutor.InterruptSummary) []I
|
||||
return ret
|
||||
}
|
||||
|
||||
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, error) {
|
||||
func writeWorkflowRun(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string) (int64, int64, error) {
|
||||
return writeWorkflowRunWithExistingID(req, workflow, result, errorMessage, 0)
|
||||
}
|
||||
|
||||
@@ -180,15 +137,38 @@ func writeWorkflowPrepareFailedRun(req Request, errorMessage string) (int64, err
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
}
|
||||
if err := repositories.AIWorkflowRunRepository.Create(sqls.DB(), run); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return run.ID, nil
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
if err := repositories.AIWorkflowRunRepository.Create(ctx.Tx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
traceData, _ := json.Marshal(map[string]any{
|
||||
"status": "error",
|
||||
"workflowId": workflowID,
|
||||
"workflowVersionId": workflowVersionID,
|
||||
"workflowRunId": run.ID,
|
||||
})
|
||||
_, err := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
|
||||
WorkflowRunID: run.ID,
|
||||
WorkflowVersionID: workflowVersionID,
|
||||
ConversationID: req.Conversation.ID,
|
||||
AIAgentID: req.AIAgent.ID,
|
||||
SourceMessageID: req.UserMessage.ID,
|
||||
Status: "failed",
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
TraceData: string(traceData),
|
||||
StepInputPreview: "workflow preparation",
|
||||
StepOutputPreview: "",
|
||||
})
|
||||
return err
|
||||
})
|
||||
return run.ID, err
|
||||
}
|
||||
|
||||
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, error) {
|
||||
func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, result *workflowexecutor.Result, errorMessage string, existingRunID int64) (int64, int64, error) {
|
||||
if result == nil {
|
||||
return 0, nil
|
||||
return 0, 0, nil
|
||||
}
|
||||
now := time.Now()
|
||||
endedAt := now
|
||||
@@ -198,6 +178,7 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
|
||||
}
|
||||
runStatus := workflowRunStatus(result.Status, errorMessage)
|
||||
var runID int64
|
||||
var agentRunID int64
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
run := repositories.AIWorkflowRunRepository.Get(ctx.Tx, existingRunID)
|
||||
if run == nil {
|
||||
@@ -249,9 +230,46 @@ func writeWorkflowRunWithExistingID(req Request, workflow resolvedWorkflow, resu
|
||||
return err
|
||||
}
|
||||
}
|
||||
traceData, _ := json.Marshal(map[string]any{
|
||||
"status": result.Status,
|
||||
"workflowId": workflow.WorkflowID,
|
||||
"workflowVersionId": workflow.VersionID,
|
||||
"workflowRunId": run.ID,
|
||||
"nodePath": result.NodePath,
|
||||
})
|
||||
createdAgentRunID, recordErr := svc.AgentRunService.RecordWorkflowRun(ctx.Tx, svc.WorkflowAgentRunInput{
|
||||
WorkflowRunID: run.ID,
|
||||
WorkflowVersionID: workflow.VersionID,
|
||||
ConversationID: req.Conversation.ID,
|
||||
AIAgentID: req.AIAgent.ID,
|
||||
SourceMessageID: req.UserMessage.ID,
|
||||
Status: workflowAgentRunStatus(result.Status, errorMessage),
|
||||
PromptTokens: result.PromptTokens,
|
||||
CompletionTokens: result.CompletionTokens,
|
||||
StartedAt: now,
|
||||
EndedAt: &endedAt,
|
||||
ErrorMessage: errorMessage,
|
||||
TraceData: string(traceData),
|
||||
StepInputPreview: "workflow execution",
|
||||
StepOutputPreview: strings.Join(result.NodePath, ","),
|
||||
})
|
||||
if recordErr != nil {
|
||||
return recordErr
|
||||
}
|
||||
agentRunID = createdAgentRunID
|
||||
return nil
|
||||
})
|
||||
return runID, err
|
||||
return runID, agentRunID, err
|
||||
}
|
||||
|
||||
func workflowAgentRunStatus(status string, errorMessage string) string {
|
||||
if strings.TrimSpace(errorMessage) != "" || strings.TrimSpace(status) == "error" {
|
||||
return "failed"
|
||||
}
|
||||
if strings.TrimSpace(status) == "interrupted" {
|
||||
return "interrupted"
|
||||
}
|
||||
return "completed"
|
||||
}
|
||||
|
||||
func workflowRunStatus(status string, errorMessage string) int {
|
||||
|
||||
@@ -2,32 +2,49 @@ package runtime
|
||||
|
||||
import (
|
||||
"agent-desk/internal/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
// RunInput is the normalized, fully prepared input shared by all Engine
|
||||
// implementations. Persistent adapters load this object before dispatching
|
||||
// into the runtime.
|
||||
type RunInput struct {
|
||||
Conversation models.Conversation
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
CheckPointID string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type ResumeRequest struct {
|
||||
// Request remains as a compatibility alias while callers move to RunInput.
|
||||
type Request = RunInput
|
||||
|
||||
// ResumeInput extends the prepared input with an approved interrupt payload.
|
||||
// It deliberately carries the same persisted context as RunInput so resume
|
||||
// semantics are consistent across Workflow, Autonomous, and Hybrid engines.
|
||||
type ResumeInput struct {
|
||||
Conversation models.Conversation
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
CheckPointID string
|
||||
ResumeData map[string]string
|
||||
Debug bool
|
||||
}
|
||||
|
||||
// ResumeRequest remains as a compatibility alias while callers move to ResumeInput.
|
||||
type ResumeRequest = ResumeInput
|
||||
|
||||
type InterruptContextSummary struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
ID string `json:"id"`
|
||||
InfoPreview string `json:"infoPreview,omitempty"`
|
||||
}
|
||||
|
||||
type Summary struct {
|
||||
// RunResult is the normalized result returned by every Engine. Engine-specific
|
||||
// details are represented by optional fields rather than engine-specific DTOs.
|
||||
type RunResult struct {
|
||||
RunID string
|
||||
Status string
|
||||
ReplyText string
|
||||
@@ -47,11 +64,39 @@ type Summary struct {
|
||||
WorkflowID int64
|
||||
WorkflowVersionID int64
|
||||
WorkflowRunID int64
|
||||
AgentRunID int64
|
||||
WorkflowNodePath []string
|
||||
CheckPointID string
|
||||
CheckPointData string
|
||||
Interrupted bool
|
||||
HandoffRequested bool
|
||||
Interrupts []InterruptContextSummary
|
||||
TraceData string
|
||||
ErrorMessage string
|
||||
}
|
||||
|
||||
// Summary remains as a compatibility alias while callers move to RunResult.
|
||||
type Summary = RunResult
|
||||
|
||||
type StreamEventType string
|
||||
|
||||
const (
|
||||
StreamEventStarted StreamEventType = "started"
|
||||
StreamEventStep StreamEventType = "step"
|
||||
StreamEventOutput StreamEventType = "output"
|
||||
StreamEventCompleted StreamEventType = "completed"
|
||||
StreamEventFailed StreamEventType = "failed"
|
||||
)
|
||||
|
||||
// StreamEvent is the transport-neutral event contract for future streaming
|
||||
// endpoints. Engines may emit partial output, audit steps, or a terminal state
|
||||
// without exposing engine-specific event payloads to callers.
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType `json:"type"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
AgentRunID int64 `json:"agentRunId,omitempty"`
|
||||
StepCode string `json:"stepCode,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
svc "agent-desk/internal/services"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
@@ -27,7 +29,7 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||
Interrupts: []workflowexecutor.InterruptSummary{
|
||||
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||
},
|
||||
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33)
|
||||
}, "test-model", resolvedWorkflow{WorkflowID: 11, VersionID: 22}, 33, 44)
|
||||
|
||||
if summary == nil || !summary.Interrupted {
|
||||
t.Fatalf("expected interrupted summary, got %#v", summary)
|
||||
@@ -41,6 +43,9 @@ func TestToWorkflowSummaryPreservesInterruptCheckpoint(t *testing.T) {
|
||||
if summary.WorkflowID != 11 || summary.WorkflowVersionID != 22 || summary.WorkflowRunID != 33 {
|
||||
t.Fatalf("unexpected workflow identity: workflow=%d version=%d run=%d", summary.WorkflowID, summary.WorkflowVersionID, summary.WorkflowRunID)
|
||||
}
|
||||
if summary.AgentRunID != 44 {
|
||||
t.Fatalf("unexpected agent run id: %d", summary.AgentRunID)
|
||||
}
|
||||
if len(summary.Interrupts) != 1 || summary.Interrupts[0].ID != "confirm_1" {
|
||||
t.Fatalf("unexpected interrupts: %#v", summary.Interrupts)
|
||||
}
|
||||
@@ -125,6 +130,9 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
|
||||
if summary.WorkflowRunID <= 0 {
|
||||
t.Fatalf("expected workflow run id in resume summary")
|
||||
}
|
||||
if summary.AgentRunID <= 0 {
|
||||
t.Fatalf("expected generic agent run id in resume summary")
|
||||
}
|
||||
var run models.AIWorkflowRun
|
||||
if err := db.First(&run, summary.WorkflowRunID).Error; err != nil {
|
||||
t.Fatalf("find resume workflow run: %v", err)
|
||||
@@ -132,6 +140,68 @@ func TestServiceResumeUsesWorkflowCheckpointData(t *testing.T) {
|
||||
if run.MessageID != 2 || run.Status != workflowRunStatusCompleted {
|
||||
t.Fatalf("unexpected resume workflow run: %#v", run)
|
||||
}
|
||||
var agentRun models.AgentRun
|
||||
if err := db.First(&agentRun, "workflow_run_id = ?", summary.WorkflowRunID).Error; err != nil {
|
||||
t.Fatalf("find generic agent run: %v", err)
|
||||
}
|
||||
if agentRun.EngineCode != EngineCodeWorkflow || agentRun.Status != "completed" {
|
||||
t.Fatalf("unexpected generic agent run: %#v", agentRun)
|
||||
}
|
||||
var stepCount int64
|
||||
if err := db.Model(&models.AgentStep{}).Where("agent_run_id = ?", agentRun.ID).Count(&stepCount).Error; err != nil {
|
||||
t.Fatalf("count generic agent steps: %v", err)
|
||||
}
|
||||
if stepCount != 1 {
|
||||
t.Fatalf("expected one generic agent step, got %d", stepCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHybridEngineResumeCompletesOriginalAgentRun(t *testing.T) {
|
||||
db := setupWorkflowResumeTestDB(t)
|
||||
def := runtimeHumanConfirmDefinition()
|
||||
version := models.AIWorkflowVersion{
|
||||
WorkflowID: 1,
|
||||
Version: 1,
|
||||
Status: enums.StatusOk,
|
||||
Definition: mustMarshalDefinition(t, def),
|
||||
}
|
||||
if err := db.Create(&version).Error; err != nil {
|
||||
t.Fatalf("create workflow version: %v", err)
|
||||
}
|
||||
startedAt := time.Now()
|
||||
hybridRun := models.AgentRun{AIAgentID: 1, EngineCode: "hybrid", Status: "interrupted", StartedAt: startedAt, EndedAt: &startedAt, CreatedAt: startedAt, UpdatedAt: startedAt}
|
||||
if err := db.Create(&hybridRun).Error; err != nil {
|
||||
t.Fatalf("create interrupted hybrid run: %v", err)
|
||||
}
|
||||
interruptedRun := models.AIWorkflowRun{WorkflowID: version.WorkflowID, WorkflowVersionID: version.ID, ConversationID: 1, AIAgentID: 1, MessageID: 2, Status: workflowRunStatusInterrupted}
|
||||
if err := db.Create(&interruptedRun).Error; err != nil {
|
||||
t.Fatalf("create interrupted workflow run: %v", err)
|
||||
}
|
||||
const checkpointID = "workflow:1:2:confirm_1"
|
||||
if err := db.Create(&models.ConversationInterrupt{
|
||||
ConversationID: 1, AIAgentID: 1, AgentRunID: hybridRun.ID,
|
||||
CheckPointID: checkpointID, InterruptID: "confirm_1", InterruptType: "human_confirm",
|
||||
WorkflowRunID: interruptedRun.ID, WorkflowNodeID: "confirm_1", RequestData: mustMarshalWorkflowCheckpoint(t, def), Status: "pending",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create interrupt: %v", err)
|
||||
}
|
||||
|
||||
summary, err := NewHybridEngine().Resume(context.Background(), ResumeRequest{
|
||||
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 3, Content: "确认"},
|
||||
AIAgent: models.AIAgent{ID: 1, RuntimeMode: enums.AIAgentRuntimeModeHybrid, WorkflowVersionID: version.ID},
|
||||
AIConfig: models.AIConfig{ModelName: "test-model"}, CheckPointID: checkpointID,
|
||||
ResumeData: map[string]string{"confirm_1": "确认"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("resume hybrid playbook: %v", err)
|
||||
}
|
||||
if summary == nil || summary.Status != "completed" || summary.AgentRunID != hybridRun.ID || summary.WorkflowRunID != interruptedRun.ID {
|
||||
t.Fatalf("unexpected hybrid resume summary: %#v", summary)
|
||||
}
|
||||
item, steps, _ := svc.AgentRunService.GetDetail(hybridRun.ID)
|
||||
if item == nil || item.Status != "completed" || len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != interruptedRun.ID {
|
||||
t.Fatalf("expected original hybrid run to receive resume audit, run=%#v steps=%#v", item, steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceResumeReusesInterruptedWorkflowRun(t *testing.T) {
|
||||
@@ -262,6 +332,13 @@ func TestServiceRunWritesFailedWorkflowRun(t *testing.T) {
|
||||
if badNodeRun.Status != workflowRunStatusFailed || badNodeRun.ErrorMessage == "" {
|
||||
t.Fatalf("unexpected failed node run: %#v", badNodeRun)
|
||||
}
|
||||
var agentRun models.AgentRun
|
||||
if err := db.First(&agentRun, "workflow_run_id = ?", run.ID).Error; err != nil {
|
||||
t.Fatalf("find generic failed agent run: %v", err)
|
||||
}
|
||||
if agentRun.Status != "failed" || !strings.Contains(agentRun.ErrorMessage, "unsupported workflow node type") {
|
||||
t.Fatalf("unexpected generic failed agent run: %#v", agentRun)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRunWritesFailedWorkflowRunWhenVersionDisabled(t *testing.T) {
|
||||
@@ -324,7 +401,15 @@ func setupWorkflowResumeTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIWorkflowVersion{}, &models.AIWorkflowRun{}, &models.AIWorkflowNodeRun{}, &models.ConversationInterrupt{}); err != nil {
|
||||
if err := db.AutoMigrate(
|
||||
&models.AIWorkflowVersion{},
|
||||
&models.AIWorkflowRun{},
|
||||
&models.AIWorkflowNodeRun{},
|
||||
&models.AgentRun{},
|
||||
&models.AgentStep{},
|
||||
&models.AgentRevision{},
|
||||
&models.ConversationInterrupt{},
|
||||
); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
@@ -49,11 +49,12 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: strings.TrimSpace(req.UserMessage),
|
||||
}
|
||||
summary, err := Service.Run(ctx, applicationruntime.Request{
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{
|
||||
Conversation: *conversation,
|
||||
UserMessage: message,
|
||||
AIAgent: debugAgent,
|
||||
AIConfig: *aiConfig,
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
return buildSkillDebugRunResponse(req, summary, skill), err
|
||||
@@ -92,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
|
||||
return nil, errorsx.InvalidParamI18n("error.e0117")
|
||||
}
|
||||
resumeText := strings.TrimSpace(req.UserMessage)
|
||||
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{
|
||||
Conversation: *conversation,
|
||||
AIAgent: *aiAgent,
|
||||
AIConfig: *aiConfig,
|
||||
@@ -100,6 +101,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(pendingInterrupt.InterruptID): resumeText,
|
||||
},
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
if isCheckpointMissingError(err) {
|
||||
|
||||
@@ -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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
@@ -31,9 +33,9 @@ func newReplyCommitService() *replyCommitService {
|
||||
}
|
||||
|
||||
func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) {
|
||||
replyText := strings.TrimSpace(input.ReplyText)
|
||||
if replyText == "" {
|
||||
return nil, nil
|
||||
replyText, err := aitooling.NormalizeCustomerReply(input.ReplyText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
|
||||
input.Conversation.ID,
|
||||
|
||||
@@ -46,6 +46,26 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) {
|
||||
db := setupReplyCommitTestDB(t)
|
||||
aiAgent := createReplyCommitTestAIAgent(t, db)
|
||||
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
|
||||
_, err := newReplyCommitService().CommitAIReply(replyCommitInput{
|
||||
Conversation: *conversation, Message: models.Message{ID: 102, RequestID: "trace-102"}, AIAgent: *aiAgent,
|
||||
ReplyText: "authorization=Bearer-secret", ClientPrefix: "ai_reply",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected sensitive model output to be rejected")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unexpected message written for rejected output: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupReplyCommitTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
@@ -13,6 +17,36 @@ func newReplyEligibility() *replyEligibility {
|
||||
return &replyEligibility{}
|
||||
}
|
||||
|
||||
// IsAIAgentRolloutEligible uses a stable conversation bucket so one customer
|
||||
// remains consistently inside or outside a gray release throughout a session.
|
||||
// Missing legacy values are treated as 100 to preserve existing behavior.
|
||||
func IsAIAgentRolloutEligible(conversation models.Conversation, aiAgent models.AIAgent, channel *models.Channel) bool {
|
||||
percent := normalizedRolloutPercent(aiAgent.RolloutPercent)
|
||||
if channel != nil {
|
||||
channelPercent := normalizedRolloutPercent(channel.AIAgentRolloutPercent)
|
||||
if channelPercent < percent {
|
||||
percent = channelPercent
|
||||
}
|
||||
}
|
||||
if percent >= 100 {
|
||||
return true
|
||||
}
|
||||
if conversation.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
seed := fmt.Sprintf("channel=%d;conversation=%d;agent=%d", conversation.ChannelID, conversation.ID, aiAgent.ID)
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
bucket := int(binary.BigEndian.Uint64(sum[:8]) % 100)
|
||||
return bucket < percent
|
||||
}
|
||||
|
||||
func normalizedRolloutPercent(percent int) int {
|
||||
if percent <= 0 || percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool {
|
||||
if message.SenderType != enums.IMSenderTypeCustomer {
|
||||
return false
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
|
||||
Interrupted: true,
|
||||
WorkflowRunID: 99,
|
||||
AgentRunID: 88,
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||
},
|
||||
@@ -58,7 +59,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
if item.RequestData != `{"confirmNodeId":"confirm_1"}` {
|
||||
t.Fatalf("unexpected request data: %q", item.RequestData)
|
||||
}
|
||||
if item.WorkflowRunID != 99 || item.WorkflowNodeID != "confirm_1" {
|
||||
if item.WorkflowRunID != 99 || item.AgentRunID != 88 || item.WorkflowNodeID != "confirm_1" {
|
||||
t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
|
||||
}
|
||||
item.ConversationID = conversation.ID
|
||||
item.AIAgentID = aiAgent.ID
|
||||
item.AgentRunID = summary.AgentRunID
|
||||
item.SourceMessageID = message.ID
|
||||
item.InterruptID = firstInterruptID(summary)
|
||||
item.InterruptType = firstInterruptType(summary)
|
||||
|
||||
@@ -82,6 +82,9 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
|
||||
|
||||
func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error {
|
||||
pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary)
|
||||
if pending != nil && pending.AgentRunID > 0 {
|
||||
pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID)
|
||||
}
|
||||
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ func TestReplyEligibilityCanReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) {
|
||||
conversation := models.Conversation{ID: 101, ChannelID: 7}
|
||||
agent := models.AIAgent{ID: 9, RolloutPercent: 50}
|
||||
first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100})
|
||||
for range 20 {
|
||||
if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first {
|
||||
t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got)
|
||||
}
|
||||
}
|
||||
if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 {
|
||||
t.Fatal("unexpected rollout percent normalization")
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) {
|
||||
t.Fatal("legacy zero rollout values must preserve full rollout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplyTimeout(t *testing.T) {
|
||||
service := newAIReplyService()
|
||||
aiAgent := newAIAgentFixture()
|
||||
|
||||
@@ -58,6 +58,9 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C
|
||||
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
|
||||
return nil
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
||||
return nil
|
||||
}
|
||||
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
||||
replyCtx.PendingInterrupt = pendingInterrupt
|
||||
return s.resumePendingInterrupt(ctx, replyCtx)
|
||||
@@ -82,6 +85,16 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
|
||||
}
|
||||
if summary != nil && summary.HandoffRequested {
|
||||
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"knowledge evidence unavailable",
|
||||
replyCtx.Message.RequestID,
|
||||
); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
_, err := s.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/models"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type runtimeReplyExecutor struct{}
|
||||
@@ -31,15 +30,10 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor {
|
||||
}
|
||||
|
||||
func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) {
|
||||
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, fmt.Errorf("ai config is nil")
|
||||
}
|
||||
summary, err := Service.Run(ctx, applicationruntime.Request{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *aiConfig,
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
})
|
||||
return summary, err
|
||||
}
|
||||
@@ -48,15 +42,12 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input
|
||||
if input.PendingInterrupt == nil {
|
||||
return nil, fmt.Errorf("pending interrupt is required")
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, fmt.Errorf("ai config is nil")
|
||||
}
|
||||
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *aiConfig,
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Resume(ctx, applicationruntime.ApplicationResumeInput{
|
||||
ApplicationRunInput: applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
},
|
||||
CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID),
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"agent-desk/internal/ai/mcps"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
@@ -167,11 +168,17 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string,
|
||||
if !containsToolCode(t.allowedToolCodes, toolCode) {
|
||||
return "", i18nx.Errorf("error.e0279")
|
||||
}
|
||||
result, err := mcps.Runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments))
|
||||
// A workflow administrator's allow-list is the explicit approval boundary
|
||||
// for MCP tools. The registry still enforces its call limit and normalizes
|
||||
// the safety metadata used by future autonomous engines.
|
||||
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{
|
||||
AllowedToolCodes: t.allowedToolCodes,
|
||||
Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildToolCallResultSummary(result), nil
|
||||
return aitooling.SanitizePreview(buildToolCallResultSummary(result)), nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) {
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
"agent-desk/internal/ai/runtime/readtools"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
"agent-desk/internal/services"
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ type Input struct {
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -355,21 +356,37 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if state.input.Debug {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"ticketId": int64(0), "ticketNo": "", "created": false,
|
||||
"message": "调试运行不会创建工单。", "skipped": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
draft := asMap(state.resolveInput(node, "ticketDraft"))
|
||||
title := strings.TrimSpace(toString(draft["title"]))
|
||||
description := strings.TrimSpace(toString(draft["description"]))
|
||||
item, err := services.TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
||||
ConversationID: state.input.Conversation.ID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
}, workflowAIPrincipal(state.input.AIAgent))
|
||||
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
||||
ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description},
|
||||
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var output struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
TicketNo string `json:"ticketNo"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.ResultData), &output); err != nil {
|
||||
return err
|
||||
}
|
||||
item := &models.Ticket{ID: output.TicketID, TicketNo: output.TicketNo}
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"ticketId": item.ID,
|
||||
"ticketNo": item.TicketNo,
|
||||
"created": true,
|
||||
"created": output.Created,
|
||||
"message": buildTicketCreatedMessage(item),
|
||||
})
|
||||
return nil
|
||||
@@ -386,18 +403,6 @@ func buildTicketCreatedMessage(item *models.Ticket) string {
|
||||
return "工单已创建,工单号:" + ticketNo + "。"
|
||||
}
|
||||
|
||||
func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
|
||||
username := strings.TrimSpace(aiAgent.Name)
|
||||
if username == "" {
|
||||
username = "AI"
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
|
||||
type workflowConversationUnderstanding struct {
|
||||
NormalizedMessage string
|
||||
MessageIntent string
|
||||
@@ -610,11 +615,14 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat
|
||||
if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" {
|
||||
input.CurrentAttempt = currentAttempt
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := graphs.NewPrepareTicketDraftGraph(state.input.Conversation).Run(ctx, string(args))
|
||||
_, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphPrepareTicketDraft.Code, map[string]any{
|
||||
"title": input.Title,
|
||||
"description": input.Description,
|
||||
"issue": input.Issue,
|
||||
"impact": input.Impact,
|
||||
"expectedOutcome": input.ExpectedOutcome,
|
||||
"currentAttempt": input.CurrentAttempt,
|
||||
}, workflowReadToolPolicy(toolx.GraphPrepareTicketDraft.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -665,11 +673,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" {
|
||||
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext"))
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := graphs.NewAnalyzeConversationGraph(state.input.Conversation).Run(ctx, string(args))
|
||||
_, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphAnalyzeConversation.Code, map[string]any{
|
||||
"goal": input.Goal,
|
||||
"observedIssue": input.ObservedIssue,
|
||||
"needTicket": input.NeedTicket,
|
||||
"needHumanHandoff": input.NeedHumanHandoff,
|
||||
"needQualityCheck": input.NeedQualityCheck,
|
||||
"additionalContext": input.AdditionalContext,
|
||||
}, workflowReadToolPolicy(toolx.GraphAnalyzeConversation.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -687,6 +698,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
return nil
|
||||
}
|
||||
|
||||
func workflowReadToolPolicy(toolCode string) aitooling.Policy {
|
||||
return aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolCode},
|
||||
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
|
||||
Confirmed: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
@@ -700,16 +719,32 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if state.input.Debug {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"handoffId": int64(0), "reason": strings.TrimSpace(toString(state.resolveInput(node, "reason"))),
|
||||
"decision": "cancelled", "teamId": int64(0), "assigneeId": int64(0),
|
||||
"message": "调试运行不会转人工。", "skipped": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
reason := strings.TrimSpace(toString(state.resolveInput(node, "reason")))
|
||||
result, err := services.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
state.input.Conversation.ID,
|
||||
state.input.AIAgent,
|
||||
reason,
|
||||
strings.TrimSpace(state.input.UserMessage.RequestID),
|
||||
)
|
||||
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
||||
ToolCode: toolx.GraphHandoffConversation.Code, Arguments: map[string]any{"reason": reason},
|
||||
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var handoff struct {
|
||||
Decision string `json:"decision"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AssigneeID int64 `json:"assigneeId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.ResultData), &handoff); err != nil {
|
||||
return err
|
||||
}
|
||||
output := map[string]any{
|
||||
"handoffId": int64(0),
|
||||
"reason": reason,
|
||||
@@ -718,24 +753,29 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
"assigneeId": int64(0),
|
||||
"message": "",
|
||||
}
|
||||
if result != nil {
|
||||
output["decision"] = string(result.Decision)
|
||||
output["teamId"] = result.TeamID
|
||||
output["assigneeId"] = result.AssigneeID
|
||||
output["message"] = strings.TrimSpace(result.Message)
|
||||
}
|
||||
output["decision"] = handoff.Decision
|
||||
output["teamId"] = handoff.TeamID
|
||||
output["assigneeId"] = handoff.AssigneeID
|
||||
output["message"] = strings.TrimSpace(handoff.Message)
|
||||
state.setNodeVars(node.ID, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func workflowToolIdempotencyKey(state *runState, node dsl.Node) string {
|
||||
requestID := strings.TrimSpace(state.input.UserMessage.RequestID)
|
||||
if requestID != "" {
|
||||
return fmt.Sprintf("workflow:%d:node:%s:request:%s", state.input.Conversation.ID, node.ID, requestID)
|
||||
}
|
||||
return fmt.Sprintf("workflow:%d:node:%s:message:%d", state.input.Conversation.ID, node.ID, state.input.UserMessage.ID)
|
||||
}
|
||||
|
||||
func (e *Executor) executeKnowledgeRetrieve(ctx context.Context, state *runState, node dsl.Node) error {
|
||||
query := strings.TrimSpace(toString(state.resolveInput(node, "query")))
|
||||
knowledgeBaseIDs := readInt64ArrayConfig(node.Data.Config, "knowledgeBaseIds")
|
||||
if len(knowledgeBaseIDs) == 0 {
|
||||
return fmt.Errorf("knowledge retrieve node requires knowledgeBaseIds")
|
||||
}
|
||||
retriever := retrievers.NewKnowledgeRetriever(state.input.AIAgent, knowledgeBaseIDs)
|
||||
result, err := retriever.RetrieveContext(ctx, query)
|
||||
_, result, err := readtools.RetrieveKnowledge(ctx, state.input.AIAgent, knowledgeBaseIDs, query, workflowReadToolPolicy(toolx.BuiltinKnowledgeRetrieve.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -490,6 +490,43 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) {
|
||||
if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") {
|
||||
t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace)
|
||||
}
|
||||
// Replaying the same confirmation checkpoint must reuse the completed
|
||||
// business-tool invocation rather than creating a second ticket.
|
||||
if _, err := executor.Resume(context.Background(), Input{
|
||||
Definition: createTicketWorkflowDefinition(), Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent,
|
||||
}, interrupted.CheckPointData, "确认"); err != nil {
|
||||
t.Fatalf("replay workflow resume: %v", err)
|
||||
}
|
||||
var ticketCount int64
|
||||
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 1 {
|
||||
t.Fatalf("ticket count after replay = %d, err=%v", ticketCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorDebugResumeDoesNotCreateTicket(t *testing.T) {
|
||||
db := setupWorkflowExecutorHandoffDB(t)
|
||||
aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1")
|
||||
conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID)
|
||||
userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单")
|
||||
executor := NewExecutor()
|
||||
definition := createTicketWorkflowDefinition()
|
||||
|
||||
interrupted, err := executor.Execute(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true})
|
||||
if err != nil || !interrupted.Interrupted {
|
||||
t.Fatalf("debug execute = %#v, err=%v", interrupted, err)
|
||||
}
|
||||
result, err := executor.Resume(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true}, interrupted.CheckPointData, "确认")
|
||||
if err != nil || result.Interrupted {
|
||||
t.Fatalf("debug resume = %#v, err=%v", result, err)
|
||||
}
|
||||
var ticketCount int64
|
||||
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 0 {
|
||||
t.Fatalf("debug ticket count = %d, err=%v", ticketCount, err)
|
||||
}
|
||||
trace := findNodeTrace(result.NodeTraces, "create_ticket_1")
|
||||
if trace == nil || !strings.Contains(trace.OutputPreview, "调试运行不会创建工单") {
|
||||
t.Fatalf("expected debug write skip trace, got %#v", trace)
|
||||
}
|
||||
}
|
||||
|
||||
func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
|
||||
@@ -835,6 +872,7 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB {
|
||||
&models.ConversationReadState{},
|
||||
&models.Message{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.AgentToolInvocation{},
|
||||
&models.Ticket{},
|
||||
&models.TicketNoSequence{},
|
||||
&models.TicketTag{},
|
||||
|
||||
@@ -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{
|
||||
Type: NodeTypeHandoffToHuman,
|
||||
Title: "Handoff To Human",
|
||||
Description: "Transfer the conversation to human support.",
|
||||
Icon: "HeadphonesIcon",
|
||||
RiskLevel: NodeRiskLevelHigh,
|
||||
Type: NodeTypeHandoffToHuman,
|
||||
Title: "Handoff To Human",
|
||||
Description: "Transfer the conversation to human support.",
|
||||
Icon: "HeadphonesIcon",
|
||||
RiskLevel: NodeRiskLevelHigh,
|
||||
RequiresConfirmationPredecessor: true,
|
||||
InputSchema: []VariableSpec{
|
||||
requiredInput("reason", "转人工原因", VariableTypeString, "触发转人工处理的业务原因。"),
|
||||
optionalInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"),
|
||||
requiredInput("confirmed", "已确认", VariableTypeBoolean, "客户是否已确认转人工。"),
|
||||
},
|
||||
OutputSchema: []VariableSpec{
|
||||
output("handoffId", "转人工记录 ID", VariableTypeInteger, "本次转人工操作的内部记录编号。"),
|
||||
|
||||
@@ -162,6 +162,32 @@ func TestValidateDefinitionRejectsConfirmedInputFromNonConfirmNode(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsHandoffWithoutConfirmedInput(t *testing.T) {
|
||||
def := dsl.Definition{
|
||||
SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
node("start_1", "start", nil, nil),
|
||||
node("confirm_1", "human_confirm", inputs("prompt", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("handoff_1", "handoff_to_human", inputs("reason", dsl.RefValue("start_1", "userMessage")), nil),
|
||||
node("end_1", "end", nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
edge("start_1", "confirm_1"),
|
||||
edge("confirm_1", "handoff_1"),
|
||||
edge("handoff_1", "end_1"),
|
||||
},
|
||||
}
|
||||
|
||||
result := validator.ValidateDefinition(def, registry.DefaultRegistry())
|
||||
|
||||
if result.Valid {
|
||||
t.Fatalf("expected handoff without confirmed input to be invalid")
|
||||
}
|
||||
if !hasValidationMessage(result, "required input mapping is missing: confirmed") {
|
||||
t.Fatalf("expected missing confirmed input error, got %#v", result.Errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDefinitionRejectsConditionBranchTargetWithoutEdge(t *testing.T) {
|
||||
def := conditionDefinition()
|
||||
def.Edges = []dsl.Edge{edge("start_1", "condition_1")}
|
||||
|
||||
@@ -178,6 +178,7 @@ func registerDashboardChannelRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/delete", dashboard.ChannelPostDelete)
|
||||
group.Any("/list", dashboard.ChannelAnyList)
|
||||
group.POST("/reset_user_token_secret", dashboard.ChannelPostReset_user_token_secret)
|
||||
group.POST("/rollback_ai_agent_rollout", dashboard.ChannelPostRollback_ai_agent_rollout)
|
||||
group.POST("/update", dashboard.ChannelPostUpdate)
|
||||
group.POST("/update_status", dashboard.ChannelPostUpdate_status)
|
||||
group.Any("/wxwork/kf/accounts", dashboard.ChannelAnyWxworkKfAccounts)
|
||||
@@ -217,6 +218,10 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
|
||||
group.POST("/workflow/save", dashboard.AIWorkflowPostSaveAgent)
|
||||
group.POST("/workflow/validate", dashboard.AIWorkflowPostValidate)
|
||||
group.POST("/workflow/publish", dashboard.AIWorkflowPostPublishAgent)
|
||||
group.POST("/publish", dashboard.AIAgentPostPublish)
|
||||
group.POST("/rollback", dashboard.AIAgentPostRollback)
|
||||
group.POST("/rollback_rollout", dashboard.AIAgentPostRollback_rollout)
|
||||
group.Any("/:id/revision/list", dashboard.AIAgentAnyRevisionList)
|
||||
group.GET("/:id", dashboard.AIAgentGetBy)
|
||||
group.POST("/create", dashboard.AIAgentPostCreate)
|
||||
group.POST("/delete", dashboard.AIAgentPostDelete)
|
||||
@@ -230,6 +235,7 @@ func registerDashboardAIAgentRoutes(group *gin.RouterGroup) {
|
||||
func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/node-spec/list", dashboard.AIWorkflowGetNodeSpecList)
|
||||
group.GET("/default-definition", dashboard.AIWorkflowGetDefaultDefinition)
|
||||
group.GET("/template/list", dashboard.AIWorkflowGetTemplateList)
|
||||
group.POST("/validate", dashboard.AIWorkflowPostValidate)
|
||||
group.Any("/run/list", dashboard.AIWorkflowAnyRunList)
|
||||
group.GET("/run/:id", dashboard.AIWorkflowGetRunBy)
|
||||
@@ -237,6 +243,15 @@ func registerDashboardAIWorkflowRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/version/:id", dashboard.AIWorkflowGetVersionBy)
|
||||
}
|
||||
|
||||
func registerDashboardAgentRunRoutes(group *gin.RouterGroup) {
|
||||
group.Any("/metrics", dashboard.AgentRunAnyMetrics)
|
||||
group.Any("/comparison", dashboard.AgentRunAnyComparison)
|
||||
group.POST("/evaluate", dashboard.AgentRunPostEvaluate)
|
||||
group.Any("/list", dashboard.AgentRunAnyList)
|
||||
group.POST("/quality_feedback", dashboard.AgentRunPostSave_quality_feedback)
|
||||
group.GET("/:id", dashboard.AgentRunGetBy)
|
||||
}
|
||||
|
||||
func registerDashboardAIConfigRoutes(group *gin.RouterGroup) {
|
||||
group.GET("/:id", dashboard.AIConfigGetBy)
|
||||
group.POST("/create", dashboard.AIConfigPostCreate)
|
||||
|
||||
@@ -189,6 +189,7 @@ func addRouter(app *gin.Engine) {
|
||||
registerDashboardAgentTeamScheduleRoutes(dashboardGroup.Group("/agent-team-schedule"))
|
||||
registerDashboardAIAgentRoutes(dashboardGroup.Group("/ai-agent"))
|
||||
registerDashboardAIWorkflowRoutes(dashboardGroup.Group("/ai-workflow"))
|
||||
registerDashboardAgentRunRoutes(dashboardGroup.Group("/agent-run"))
|
||||
registerDashboardAIConfigRoutes(dashboardGroup.Group("/ai-config"))
|
||||
registerDashboardAssetRoutes(dashboardGroup.Group("/asset"))
|
||||
registerDashboardKnowledgeBaseRoutes(dashboardGroup.Group("/knowledge-base"))
|
||||
|
||||
@@ -43,8 +43,17 @@ func TestNewServerRegistersGinRoutes(t *testing.T) {
|
||||
http.MethodPost + " /api/dashboard/user/create",
|
||||
http.MethodPost + " /api/dashboard/conversation/send_message",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/default-definition",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/template/list",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/list",
|
||||
http.MethodGet + " /api/dashboard/ai-workflow/run/:id",
|
||||
http.MethodGet + " /api/dashboard/agent-run/metrics",
|
||||
http.MethodGet + " /api/dashboard/agent-run/comparison",
|
||||
http.MethodPost + " /api/dashboard/agent-run/evaluate",
|
||||
http.MethodGet + " /api/dashboard/agent-run/:id",
|
||||
http.MethodPost + " /api/dashboard/ai-agent/rollback_rollout",
|
||||
http.MethodPost + " /api/dashboard/channel/rollback_ai_agent_rollout",
|
||||
http.MethodPost + " /api/dashboard/agent-run/quality_feedback",
|
||||
http.MethodGet + " /api/dashboard/agent-run/list",
|
||||
http.MethodGet + " /api/ws/dashboard",
|
||||
http.MethodGet + " /api/ws/open",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/services"
|
||||
)
|
||||
|
||||
func BuildAIWorkflow(item *models.AIWorkflow) response.AIWorkflowResponse {
|
||||
@@ -89,6 +90,14 @@ func BuildAIWorkflowNodeSpecs(list []workflowregistry.NodeSpec) []response.AIWor
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildAIWorkflowTemplates(list []services.AIWorkflowTemplate) []response.AIWorkflowTemplateResponse {
|
||||
ret := make([]response.AIWorkflowTemplateResponse, 0, len(list))
|
||||
for _, item := range list {
|
||||
ret = append(ret, response.AIWorkflowTemplateResponse{Code: item.Code, Name: item.Name, Description: item.Description, Definition: item.Definition})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func BuildAIWorkflowRun(item *models.AIWorkflowRun) response.AIWorkflowRunResponse {
|
||||
return BuildAIWorkflowRunWithContext(item, nil, nil, nil)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
"agent-desk/internal/builders"
|
||||
"agent-desk/internal/pkg/httpx"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
@@ -125,6 +126,77 @@ func AIAgentPostDelete(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func AIAgentPostPublish(ctx *gin.Context) {
|
||||
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
req := request.PublishAIAgentRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
_, err = services.AIAgentService.PublishAIAgent(req.ID, operator)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func AIAgentAnyRevisionList(ctx *gin.Context) {
|
||||
id, ok := httpx.GetPathInt64(ctx, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if services.AIAgentService.Get(id) == nil {
|
||||
httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0002"))
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildAgentRevisionList(services.AgentRevisionService.FindByAgentID(id)))
|
||||
}
|
||||
|
||||
func AIAgentPostRollback(ctx *gin.Context) {
|
||||
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
req := request.RollbackAIAgentRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := services.AIAgentService.RollbackAIAgent(req.ID, req.RevisionID, operator); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func AIAgentPostRollback_rollout(ctx *gin.Context) {
|
||||
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
req := request.RollbackAIAgentRolloutRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := services.AIAgentService.RollbackAIAgentRollout(req.ID, operator); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func AIAgentPostUpdate_sort(ctx *gin.Context) {
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentUpdate); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
@@ -165,36 +237,50 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
|
||||
}
|
||||
|
||||
func buildAIAgentResponseWithLocale(item *models.AIAgent, locale string) response.AIAgentResponse {
|
||||
runtimeMode := item.RuntimeMode
|
||||
if runtimeMode == "" {
|
||||
runtimeMode = enums.AIAgentRuntimeModeWorkflow
|
||||
}
|
||||
ret := response.AIAgentResponse{
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Status: item.Status,
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
AIConfigID: item.AIConfigID,
|
||||
ServiceMode: item.ServiceMode,
|
||||
ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode),
|
||||
SystemPrompt: item.SystemPrompt,
|
||||
WelcomeMessage: item.WelcomeMessage,
|
||||
ReplyTimeoutSeconds: item.ReplyTimeoutSeconds,
|
||||
HandoffMode: item.HandoffMode,
|
||||
HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode),
|
||||
FallbackMode: item.FallbackMode,
|
||||
FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode),
|
||||
FallbackMessage: item.FallbackMessage,
|
||||
SkillIDs: utils.SplitInt64s(item.SkillIDs),
|
||||
Skills: make([]response.AIAgentSkillResponse, 0),
|
||||
Teams: make([]response.AIAgentTeamResponse, 0),
|
||||
DirectTools: make([]response.AIAgentMCPToolResponse, 0),
|
||||
WorkflowVersionID: item.WorkflowVersionID,
|
||||
WorkflowPublished: item.WorkflowVersionID > 0,
|
||||
WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID),
|
||||
WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID),
|
||||
SortNo: item.SortNo,
|
||||
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
CreateUserName: item.CreateUserName,
|
||||
UpdateUserName: item.UpdateUserName,
|
||||
ID: item.ID,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Status: item.Status,
|
||||
StatusName: enums.GetStatusLabel(item.Status),
|
||||
AIConfigID: item.AIConfigID,
|
||||
RuntimeMode: runtimeMode,
|
||||
RuntimeModeName: enums.GetAIAgentRuntimeModeLabel(runtimeMode),
|
||||
MaxSteps: item.MaxSteps,
|
||||
ContextWindow: item.ContextWindow,
|
||||
ToolPolicy: item.ToolPolicy,
|
||||
KnowledgePolicy: item.KnowledgePolicy,
|
||||
ServiceMode: item.ServiceMode,
|
||||
ServiceModeName: enums.GetIMConversationServiceModeLabel(item.ServiceMode),
|
||||
SystemPrompt: item.SystemPrompt,
|
||||
WelcomeMessage: item.WelcomeMessage,
|
||||
ReplyTimeoutSeconds: item.ReplyTimeoutSeconds,
|
||||
RolloutPercent: item.RolloutPercent,
|
||||
PreviousRolloutPercent: item.PreviousRolloutPercent,
|
||||
HandoffMode: item.HandoffMode,
|
||||
HandoffModeName: enums.GetAIAgentHandoffModeLabel(item.HandoffMode),
|
||||
FallbackMode: item.FallbackMode,
|
||||
FallbackModeName: enums.GetAIAgentFallbackModeLabel(item.FallbackMode),
|
||||
FallbackMessage: item.FallbackMessage,
|
||||
KnowledgeBaseIDs: utils.SplitInt64s(item.KnowledgeIDs),
|
||||
SkillIDs: utils.SplitInt64s(item.SkillIDs),
|
||||
Skills: make([]response.AIAgentSkillResponse, 0),
|
||||
Teams: make([]response.AIAgentTeamResponse, 0),
|
||||
DirectTools: make([]response.AIAgentMCPToolResponse, 0),
|
||||
WorkflowVersionID: item.WorkflowVersionID,
|
||||
PublishedRevisionID: item.PublishedRevisionID,
|
||||
WorkflowPublished: item.WorkflowVersionID > 0,
|
||||
WorkflowState: aiAgentWorkflowState(item.WorkflowVersionID),
|
||||
WorkflowStateText: aiAgentWorkflowStateText(item.WorkflowVersionID),
|
||||
SortNo: item.SortNo,
|
||||
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: item.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
CreateUserName: item.CreateUserName,
|
||||
UpdateUserName: item.UpdateUserName,
|
||||
}
|
||||
if aiConfig := services.AIConfigService.Get(item.AIConfigID); aiConfig != nil {
|
||||
ret.AIConfigName = aiConfig.Name
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
@@ -14,6 +15,9 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) {
|
||||
setupAIAgentHandlerTestDB(t)
|
||||
|
||||
draft := buildAIAgentResponse(&models.AIAgent{})
|
||||
if draft.RuntimeMode != enums.AIAgentRuntimeModeWorkflow {
|
||||
t.Fatalf("draft.RuntimeMode = %q, want %q", draft.RuntimeMode, enums.AIAgentRuntimeModeWorkflow)
|
||||
}
|
||||
if draft.WorkflowPublished {
|
||||
t.Fatalf("draft.WorkflowPublished = true, want false")
|
||||
}
|
||||
@@ -34,6 +38,11 @@ func TestBuildAIAgentResponseExposesWorkflowPublishState(t *testing.T) {
|
||||
if published.WorkflowStateText == "" {
|
||||
t.Fatalf("expected published workflow state text")
|
||||
}
|
||||
|
||||
rollout := buildAIAgentResponse(&models.AIAgent{RolloutPercent: 20, PreviousRolloutPercent: 100})
|
||||
if rollout.RolloutPercent != 20 || rollout.PreviousRolloutPercent != 100 {
|
||||
t.Fatalf("unexpected rollout response: %#v", rollout)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAIAgentHandlerTestDB(t *testing.T) {
|
||||
|
||||
@@ -116,6 +116,14 @@ func AIWorkflowGetDefaultDefinition(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, services.AIWorkflowService.DefaultAgentWorkflowDefinition())
|
||||
}
|
||||
|
||||
func AIWorkflowGetTemplateList(ctx *gin.Context) {
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, builders.BuildAIWorkflowTemplates(services.AIWorkflowService.ListPlaybookTemplates()))
|
||||
}
|
||||
|
||||
func AIWorkflowPostValidate(ctx *gin.Context) {
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionAIAgentView); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
|
||||
@@ -100,6 +100,24 @@ func ChannelPostUpdate(ctx *gin.Context) {
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func ChannelPostRollback_ai_agent_rollout(ctx *gin.Context) {
|
||||
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
req := request.RollbackChannelAIAgentRolloutRequest{}
|
||||
if err := params.ReadJSON(ctx, &req); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
if err := services.ChannelService.RollbackChannelAIAgentRollout(req.ID, operator); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(ctx, nil)
|
||||
}
|
||||
|
||||
func ChannelPostUpdate_status(ctx *gin.Context) {
|
||||
operator, err := services.AuthService.RequirePermission(ctx, constants.PermissionChannelUpdate)
|
||||
if err != nil {
|
||||
|
||||
+135
-23
@@ -57,6 +57,12 @@ var Models = []any{
|
||||
&KnowledgeFeedback{},
|
||||
&SkillDefinition{},
|
||||
&SkillRunLog{},
|
||||
&AgentRevision{},
|
||||
&AgentRun{},
|
||||
&AgentStep{},
|
||||
&AgentToolCall{},
|
||||
&AgentToolInvocation{},
|
||||
&AgentRunQualityFeedback{},
|
||||
&AIWorkflow{},
|
||||
&AIWorkflowVersion{},
|
||||
&AIWorkflowRun{},
|
||||
@@ -65,6 +71,21 @@ var Models = []any{
|
||||
&SystemConfig{},
|
||||
}
|
||||
|
||||
// AgentToolInvocation persists the idempotency boundary for a business tool.
|
||||
// It is intentionally independent of AgentRun audit rows so a retry after a
|
||||
// process interruption cannot repeat an external write.
|
||||
type AgentToolInvocation struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ConversationID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_tool_invocation"`
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
ToolCode string `gorm:"type:varchar(128);not null;default:'';uniqueIndex:uk_agent_tool_invocation"`
|
||||
IdempotencyKey string `gorm:"type:varchar(160);not null;default:'';uniqueIndex:uk_agent_tool_invocation"`
|
||||
Status string `gorm:"type:varchar(20);not null;default:'running';index"`
|
||||
ResultData string `gorm:"type:text"`
|
||||
ErrorMessage string `gorm:"type:text"`
|
||||
AuditFields
|
||||
}
|
||||
|
||||
type Migration struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
Version int64 `gorm:"type:bigint;not null;uniqueIndex"`
|
||||
@@ -508,24 +529,111 @@ type QuickReply struct {
|
||||
|
||||
// AIAgent AI 接待实例。
|
||||
type AIAgent struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。
|
||||
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。
|
||||
Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。
|
||||
Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent
|
||||
AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。
|
||||
ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管。
|
||||
SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词。
|
||||
WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板。
|
||||
ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数。
|
||||
TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔。
|
||||
HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池。
|
||||
FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略。
|
||||
FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案。
|
||||
KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级。
|
||||
SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围。
|
||||
AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON。
|
||||
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID。
|
||||
SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为 AI Agent 主键。
|
||||
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为 AI Agent 名称。
|
||||
Description string `gorm:"type:varchar(255);not null;default:''"` // Description 为 AI Agent 描述。
|
||||
Status enums.Status `gorm:"type:int;not null;index"` // Status 为 AI Agent
|
||||
AIConfigID int64 `gorm:"type:bigint;not null;default:0;index"` // AIConfigID 为关联的 AI 配置ID。
|
||||
RuntimeMode enums.AIAgentRuntimeMode `gorm:"type:varchar(30);not null;default:'workflow';index"` // RuntimeMode 为 Agent 的运行引擎模式。
|
||||
MaxSteps int `gorm:"type:int;not null;default:6"` // MaxSteps 为一次自主运行允许的最大推理步骤数。
|
||||
ContextWindow int `gorm:"type:int;not null;default:0"` // ContextWindow 为会话上下文消息窗口,0 表示使用运行时默认值。
|
||||
ToolPolicy string `gorm:"type:text"` // ToolPolicy 为工具风险与确认策略JSON。
|
||||
KnowledgePolicy string `gorm:"type:text"` // KnowledgePolicy 为知识检索与无依据回答策略JSON。
|
||||
ServiceMode enums.IMConversationServiceMode `gorm:"type:int;not null;default:3;index"` // ServiceMode 为服务模式,如仅AI、仅人工、AI优先人工接管。
|
||||
SystemPrompt string `gorm:"type:text"` // SystemPrompt 为该 Agent 的系统提示词。
|
||||
WelcomeMessage string `gorm:"type:text"` // WelcomeMessage 为该 Agent 的欢迎语或首响模板。
|
||||
ReplyTimeoutSeconds int `gorm:"type:int;not null;default:180"` // ReplyTimeoutSeconds 为异步自动回复超时秒数。
|
||||
RolloutPercent int `gorm:"type:int;not null;default:100"` // RolloutPercent 为该 Agent 的会话灰度百分比,100 表示全量。
|
||||
PreviousRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousRolloutPercent 保存上一次生效的灰度比例,0 表示尚无可回滚值。
|
||||
TeamIDs string `gorm:"type:varchar(500);not null;default:''"` // TeamIDs 为转人工时可路由的客服组ID列表,多个之间使用逗号分隔。
|
||||
HandoffMode enums.AIAgentHandoffMode `gorm:"type:int;not null;default:1"` // HandoffMode 为转人工执行方式,如进入待接入池、进入默认客服组待接入池。
|
||||
FallbackMode enums.AIAgentFallbackMode `gorm:"type:int;not null;default:1"` // FallbackMode 为知识不足时的回复策略。
|
||||
FallbackMessage string `gorm:"type:text"` // FallbackMessage 为知识不足回复文案。
|
||||
KnowledgeIDs string `gorm:"type:varchar(500);not null;default:''"` // KnowledgeIDs 为绑定的知识库ID列表,按顺序表示优先级。
|
||||
SkillIDs string `gorm:"type:varchar(500);not null;default:''"` // SkillIDs 为绑定的技能ID列表,按顺序表示允许路由的范围。
|
||||
AllowedMCPTools string `gorm:"type:text"` // AllowedMCPTools 为允许 direct tool 路由的 MCP 工具白名单配置JSON。
|
||||
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"` // WorkflowVersionID 为绑定的已发布会话流程版本ID。
|
||||
PublishedRevisionID int64 `gorm:"type:bigint;not null;default:0;index"` // PublishedRevisionID 为当前已发布 Agent 配置快照ID。
|
||||
SortNo int `gorm:"type:int;not null;default:0;index"` // SortNo 为后台展示排序号。
|
||||
AuditFields
|
||||
}
|
||||
|
||||
// AgentRevision stores an immutable published Agent configuration snapshot.
|
||||
type AgentRevision struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AgentID int64 `gorm:"type:bigint;not null;index;uniqueIndex:uk_agent_revision"`
|
||||
Revision int `gorm:"type:int;not null;uniqueIndex:uk_agent_revision"`
|
||||
WorkflowVersionID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
Status enums.Status `gorm:"type:int;not null;default:0;index"`
|
||||
Definition string `gorm:"type:longtext"`
|
||||
DefinitionHash string `gorm:"type:varchar(64);not null;default:'';index"`
|
||||
PublishedAt *time.Time `gorm:"type:datetime;index"`
|
||||
PublishedByID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
PublishedByName string `gorm:"type:varchar(100);not null;default:''"`
|
||||
AuditFields
|
||||
}
|
||||
|
||||
// AgentRun is an Engine-independent record for one Agent reply execution.
|
||||
type AgentRun struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AgentRevisionID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
EngineCode string `gorm:"type:varchar(50);not null;default:'';index"`
|
||||
Status string `gorm:"type:varchar(30);not null;default:'';index"`
|
||||
PromptTokens int `gorm:"type:int;not null;default:0"`
|
||||
CompletionTokens int `gorm:"type:int;not null;default:0"`
|
||||
StartedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
EndedAt *time.Time `gorm:"type:datetime;index"`
|
||||
ErrorMessage string `gorm:"type:text"`
|
||||
TraceData string `gorm:"type:text"`
|
||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
UpdatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
}
|
||||
|
||||
// AgentStep records a normalized model, tool, workflow, or policy transition.
|
||||
type AgentStep struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AgentRunID int64 `gorm:"type:bigint;not null;index"`
|
||||
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
StepType string `gorm:"type:varchar(50);not null;default:'';index"`
|
||||
StepCode string `gorm:"type:varchar(100);not null;default:'';index"`
|
||||
Status string `gorm:"type:varchar(30);not null;default:'';index"`
|
||||
InputPreview string `gorm:"type:text"`
|
||||
OutputPreview string `gorm:"type:text"`
|
||||
ErrorMessage string `gorm:"type:text"`
|
||||
StartedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
EndedAt *time.Time `gorm:"type:datetime;index"`
|
||||
DurationMS int `gorm:"type:int;not null;default:0"`
|
||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
}
|
||||
|
||||
// AgentToolCall records the safety-relevant details of a normalized tool call.
|
||||
type AgentToolCall struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AgentRunID int64 `gorm:"type:bigint;not null;index"`
|
||||
AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
ToolCode string `gorm:"type:varchar(150);not null;default:'';index"`
|
||||
RiskLevel string `gorm:"type:varchar(30);not null;default:'';index"`
|
||||
RequireConfirm bool `gorm:"not null;default:false"`
|
||||
Status string `gorm:"type:varchar(30);not null;default:'';index"`
|
||||
ArgumentsPreview string `gorm:"type:text"`
|
||||
ResultPreview string `gorm:"type:text"`
|
||||
ErrorMessage string `gorm:"type:text"`
|
||||
DurationMS int `gorm:"type:int;not null;default:0"`
|
||||
CreatedAt time.Time `gorm:"type:datetime;not null;index"`
|
||||
}
|
||||
|
||||
// AgentRunQualityFeedback is an operator-provided quality review for one
|
||||
// normalized Agent run. Runtime completion must not be treated as resolution.
|
||||
type AgentRunQualityFeedback struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
AgentRunID int64 `gorm:"type:bigint;not null;uniqueIndex"`
|
||||
ResolutionStatus enums.AgentRunResolutionStatus `gorm:"type:varchar(20);not null;default:'unknown';index"`
|
||||
EvidenceStatus enums.AgentRunEvidenceStatus `gorm:"type:varchar(20);not null;default:'unknown';index"`
|
||||
Comment string `gorm:"type:text"`
|
||||
AuditFields
|
||||
}
|
||||
|
||||
@@ -595,11 +703,13 @@ type AIWorkflowNodeRun struct {
|
||||
// 渠道本身负责定义“入口如何识别、默认接入哪个 AI Agent、渠道专属配置是什么”,
|
||||
// 而具体消息收发、会话映射等运行时数据由各自的渠道业务表承载。
|
||||
type Channel struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。
|
||||
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。
|
||||
ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。
|
||||
ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"` // ID 为渠道主键。
|
||||
Name string `gorm:"type:varchar(100);not null;default:'';index"` // Name 为渠道名称,用于后台展示和业务识别,例如“官网客服”“企业微信主客服”。
|
||||
ChannelType string `gorm:"type:varchar(30);not null;default:'';index"` // ChannelType 为渠道类型,决定该渠道的接入方式和配置解释规则。当前规划的典型取值包括:web、wxwork_kf。
|
||||
ChannelID string `gorm:"type:varchar(64);not null;default:'';uniqueIndex"` // ChannelID 为渠道入口标识,由系统自动生成。对 web 渠道,该字段用于前端通过 X-Channel-Id 标识接入来源;对其他渠道,作为统一的系统内稳定渠道标识保留。
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"` // AIAgentID 为该渠道默认接入的 AI Agent。 当外部客户通过该渠道首次进入系统且尚未命中现有未结束会话时,系统会使用该 AI Agent 作为会话默认接待实例。
|
||||
AIAgentRolloutPercent int `gorm:"type:int;not null;default:100"` // AIAgentRolloutPercent 为该渠道对 AI 自动回复施加的灰度百分比,100 表示不额外限制。
|
||||
PreviousAIAgentRolloutPercent int `gorm:"type:int;not null;default:0"` // PreviousAIAgentRolloutPercent 保存渠道上一次生效的 Agent 灰度比例,0 表示尚无可回滚值。
|
||||
// ConfigJSON 为渠道专属扩展配置,使用 JSON 存储。
|
||||
// 例如:
|
||||
// 1. web 渠道可记录允许域名、品牌配置等;
|
||||
@@ -914,6 +1024,8 @@ type ConversationInterrupt struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement"`
|
||||
ConversationID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AIAgentID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AgentRunID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
AgentStepID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
SourceMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
LastResumeMessageID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
WorkflowRunID int64 `gorm:"type:bigint;not null;default:0;index"`
|
||||
|
||||
@@ -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"`
|
||||
Description string `json:"description"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
TeamIDs []int64 `json:"teamIds"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
DirectTools []AIAgentMCPToolRequest `json:"directTools"`
|
||||
}
|
||||
@@ -67,6 +74,19 @@ type DeleteAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type PublishAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type RollbackAIAgentRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
RevisionID int64 `json:"revisionId"`
|
||||
}
|
||||
|
||||
type RollbackAIAgentRolloutRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type UpdateAIAgentStatusRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
Status int `json:"status"`
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
package request
|
||||
|
||||
type CreateChannelRequest struct {
|
||||
ChannelType string `json:"channelType"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
ChannelType string `json:"channelType"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status int `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type UpdateChannelRequest struct {
|
||||
@@ -19,6 +20,10 @@ type UpdateChannelStatusRequest struct {
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
type RollbackChannelAIAgentRolloutRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type DeleteChannelRequest struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
type AgentRevisionResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
AgentID int64 `json:"agentId"`
|
||||
Revision int `json:"revision"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
Status enums.Status `json:"status"`
|
||||
DefinitionHash string `json:"definitionHash"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
PublishedByID int64 `json:"publishedById"`
|
||||
PublishedByName string `json:"publishedByName"`
|
||||
}
|
||||
|
||||
type AIConfigResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
@@ -67,34 +79,44 @@ func BuildAIConfigResponse(item *models.AIConfig) AIConfigResponse {
|
||||
}
|
||||
|
||||
type AIAgentResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
AIConfigName string `json:"aiConfigName"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
ServiceModeName string `json:"serviceModeName"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
Teams []AIAgentTeamResponse `json:"teams"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
HandoffModeName string `json:"handoffModeName"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackModeName string `json:"fallbackModeName"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
Skills []AIAgentSkillResponse `json:"skills"`
|
||||
DirectTools []AIAgentMCPToolResponse `json:"directTools"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
WorkflowPublished bool `json:"workflowPublished"`
|
||||
WorkflowState string `json:"workflowState"`
|
||||
WorkflowStateText string `json:"workflowStateText"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status enums.Status `json:"status"`
|
||||
StatusName string `json:"statusName"`
|
||||
AIConfigID int64 `json:"aiConfigId"`
|
||||
AIConfigName string `json:"aiConfigName"`
|
||||
RuntimeMode enums.AIAgentRuntimeMode `json:"runtimeMode"`
|
||||
RuntimeModeName string `json:"runtimeModeName"`
|
||||
MaxSteps int `json:"maxSteps"`
|
||||
ContextWindow int `json:"contextWindow"`
|
||||
ToolPolicy string `json:"toolPolicy"`
|
||||
KnowledgePolicy string `json:"knowledgePolicy"`
|
||||
ServiceMode enums.IMConversationServiceMode `json:"serviceMode"`
|
||||
ServiceModeName string `json:"serviceModeName"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
WelcomeMessage string `json:"welcomeMessage"`
|
||||
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
|
||||
RolloutPercent int `json:"rolloutPercent"`
|
||||
PreviousRolloutPercent int `json:"previousRolloutPercent"`
|
||||
Teams []AIAgentTeamResponse `json:"teams"`
|
||||
HandoffMode enums.AIAgentHandoffMode `json:"handoffMode"`
|
||||
HandoffModeName string `json:"handoffModeName"`
|
||||
FallbackMode enums.AIAgentFallbackMode `json:"fallbackMode"`
|
||||
FallbackModeName string `json:"fallbackModeName"`
|
||||
FallbackMessage string `json:"fallbackMessage"`
|
||||
KnowledgeBaseIDs []int64 `json:"knowledgeBaseIds"`
|
||||
SkillIDs []int64 `json:"skillIds"`
|
||||
Skills []AIAgentSkillResponse `json:"skills"`
|
||||
DirectTools []AIAgentMCPToolResponse `json:"directTools"`
|
||||
WorkflowVersionID int64 `json:"workflowVersionId"`
|
||||
PublishedRevisionID int64 `json:"publishedRevisionId"`
|
||||
WorkflowPublished bool `json:"workflowPublished"`
|
||||
WorkflowState string `json:"workflowState"`
|
||||
WorkflowStateText string `json:"workflowStateText"`
|
||||
SortNo int `json:"sortNo"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
CreateUserName string `json:"createUserName"`
|
||||
UpdateUserName string `json:"updateUserName"`
|
||||
}
|
||||
|
||||
@@ -41,6 +41,13 @@ type AIWorkflowValidationResponse struct {
|
||||
Errors []workflowvalidator.Error `json:"errors"`
|
||||
}
|
||||
|
||||
type AIWorkflowTemplateResponse struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Definition dsl.Definition `json:"definition"`
|
||||
}
|
||||
|
||||
type AIWorkflowNodeSpecResponse struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
|
||||
@@ -6,15 +6,17 @@ import (
|
||||
)
|
||||
|
||||
type ChannelResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
ChannelType string `json:"channelType"`
|
||||
ChannelID string `json:"channelId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentName string `json:"aiAgentName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
ID int64 `json:"id"`
|
||||
ChannelType string `json:"channelType"`
|
||||
ChannelID string `json:"channelId"`
|
||||
AIAgentID int64 `json:"aiAgentId"`
|
||||
AIAgentRolloutPercent int `json:"aiAgentRolloutPercent"`
|
||||
PreviousAIAgentRolloutPercent int `json:"previousAiAgentRolloutPercent"`
|
||||
AIAgentName string `json:"aiAgentName,omitempty"`
|
||||
Name string `json:"name"`
|
||||
ConfigJSON string `json:"configJson"`
|
||||
Status enums.Status `json:"status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type WxWorkKFAccountResponse struct {
|
||||
@@ -29,13 +31,15 @@ func BuildChannelResponse(item *models.Channel) ChannelResponse {
|
||||
return ChannelResponse{}
|
||||
}
|
||||
return ChannelResponse{
|
||||
ID: item.ID,
|
||||
ChannelType: item.ChannelType,
|
||||
ChannelID: item.ChannelID,
|
||||
AIAgentID: item.AIAgentID,
|
||||
Name: item.Name,
|
||||
ConfigJSON: item.ConfigJSON,
|
||||
Status: item.Status,
|
||||
Remark: item.Remark,
|
||||
ID: item.ID,
|
||||
ChannelType: item.ChannelType,
|
||||
ChannelID: item.ChannelID,
|
||||
AIAgentID: item.AIAgentID,
|
||||
AIAgentRolloutPercent: item.AIAgentRolloutPercent,
|
||||
PreviousAIAgentRolloutPercent: item.PreviousAIAgentRolloutPercent,
|
||||
Name: item.Name,
|
||||
ConfigJSON: item.ConfigJSON,
|
||||
Status: item.Status,
|
||||
Remark: item.Remark,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
package enums
|
||||
|
||||
type AgentRunResolutionStatus string
|
||||
|
||||
const (
|
||||
AgentRunResolutionStatusUnknown AgentRunResolutionStatus = "unknown"
|
||||
AgentRunResolutionStatusResolved AgentRunResolutionStatus = "resolved"
|
||||
AgentRunResolutionStatusUnresolved AgentRunResolutionStatus = "unresolved"
|
||||
)
|
||||
|
||||
var AgentRunResolutionStatusValues = []AgentRunResolutionStatus{
|
||||
AgentRunResolutionStatusUnknown,
|
||||
AgentRunResolutionStatusResolved,
|
||||
AgentRunResolutionStatusUnresolved,
|
||||
}
|
||||
|
||||
type AgentRunEvidenceStatus string
|
||||
|
||||
const (
|
||||
AgentRunEvidenceStatusUnknown AgentRunEvidenceStatus = "unknown"
|
||||
AgentRunEvidenceStatusSupported AgentRunEvidenceStatus = "supported"
|
||||
AgentRunEvidenceStatusUnsupported AgentRunEvidenceStatus = "unsupported"
|
||||
)
|
||||
|
||||
var AgentRunEvidenceStatusValues = []AgentRunEvidenceStatus{
|
||||
AgentRunEvidenceStatusUnknown,
|
||||
AgentRunEvidenceStatusSupported,
|
||||
AgentRunEvidenceStatusUnsupported,
|
||||
}
|
||||
|
||||
type ServiceStatus int
|
||||
|
||||
const (
|
||||
|
||||
@@ -220,22 +220,58 @@ type AIAgentFallbackMode int
|
||||
const (
|
||||
AIAgentFallbackModeNoAnswer AIAgentFallbackMode = 1
|
||||
AIAgentFallbackModeSuggestRetry AIAgentFallbackMode = 2
|
||||
AIAgentFallbackModeHandoff AIAgentFallbackMode = 3
|
||||
)
|
||||
|
||||
var AIAgentFallbackModeValues = []AIAgentFallbackMode{
|
||||
AIAgentFallbackModeNoAnswer,
|
||||
AIAgentFallbackModeSuggestRetry,
|
||||
AIAgentFallbackModeHandoff,
|
||||
}
|
||||
|
||||
var aiAgentFallbackModeLabelMap = map[AIAgentFallbackMode]string{
|
||||
AIAgentFallbackModeNoAnswer: "直接说明知识不足",
|
||||
AIAgentFallbackModeSuggestRetry: "引导用户补充信息",
|
||||
AIAgentFallbackModeHandoff: "转人工客服",
|
||||
}
|
||||
|
||||
func GetAIAgentFallbackModeLabel(mode AIAgentFallbackMode) string {
|
||||
return aiAgentFallbackModeLabelMap[mode]
|
||||
}
|
||||
|
||||
type AIAgentRuntimeMode string
|
||||
|
||||
const (
|
||||
AIAgentRuntimeModeWorkflow AIAgentRuntimeMode = "workflow"
|
||||
AIAgentRuntimeModeAutonomous AIAgentRuntimeMode = "autonomous"
|
||||
AIAgentRuntimeModeHybrid AIAgentRuntimeMode = "hybrid"
|
||||
)
|
||||
|
||||
var AIAgentRuntimeModeValues = []AIAgentRuntimeMode{
|
||||
AIAgentRuntimeModeWorkflow,
|
||||
AIAgentRuntimeModeAutonomous,
|
||||
AIAgentRuntimeModeHybrid,
|
||||
}
|
||||
|
||||
var aiAgentRuntimeModeLabelMap = map[AIAgentRuntimeMode]string{
|
||||
AIAgentRuntimeModeWorkflow: "流程编排",
|
||||
AIAgentRuntimeModeAutonomous: "自主运行",
|
||||
AIAgentRuntimeModeHybrid: "混合运行",
|
||||
}
|
||||
|
||||
func GetAIAgentRuntimeModeLabel(mode AIAgentRuntimeMode) string {
|
||||
return aiAgentRuntimeModeLabelMap[mode]
|
||||
}
|
||||
|
||||
func IsValidAIAgentRuntimeMode(mode AIAgentRuntimeMode) bool {
|
||||
for _, item := range AIAgentRuntimeModeValues {
|
||||
if item == mode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const (
|
||||
IMRealtimeEventConnected = "connected"
|
||||
IMRealtimeEventPong = "pong"
|
||||
|
||||
@@ -57,6 +57,24 @@ var (
|
||||
SourceType: enums.ToolSourceTypeBuiltin,
|
||||
AutoInjected: true,
|
||||
}
|
||||
BuiltinConversationContext = ToolSpec{
|
||||
Code: "builtin/conversation_context",
|
||||
ServerCode: "builtin",
|
||||
Name: "conversation_context",
|
||||
Title: "会话上下文",
|
||||
Description: "读取当前客户基础信息和会话摘要。",
|
||||
SourceType: enums.ToolSourceTypeBuiltin,
|
||||
DirectAccess: true,
|
||||
}
|
||||
BuiltinKnowledgeRetrieve = ToolSpec{
|
||||
Code: "builtin/knowledge_retrieve",
|
||||
ServerCode: "builtin",
|
||||
Name: "knowledge_retrieve",
|
||||
Title: "知识检索",
|
||||
Description: "在当前 Agent 已绑定的知识库中检索证据。",
|
||||
SourceType: enums.ToolSourceTypeBuiltin,
|
||||
DirectAccess: true,
|
||||
}
|
||||
GraphTriageServiceRequest = ToolSpec{
|
||||
Code: "graph/triage_service_request",
|
||||
ServerCode: "graph",
|
||||
@@ -66,6 +84,7 @@ var (
|
||||
Description: i18nx.Get("tool.graph.triageServiceRequest.description"),
|
||||
DescriptionKey: "tool.graph.triageServiceRequest.description",
|
||||
SourceType: enums.ToolSourceTypeGraph,
|
||||
DirectAccess: true,
|
||||
RuntimeStatic: true,
|
||||
Appendix: i18nx.Get("tool.graph.triageServiceRequest.appendix"),
|
||||
AppendixKey: "tool.graph.triageServiceRequest.appendix",
|
||||
@@ -80,6 +99,7 @@ var (
|
||||
DescriptionKey: "tool.graph.analyzeConversation.description",
|
||||
SourceType: enums.ToolSourceTypeGraph,
|
||||
RuntimeStatic: true,
|
||||
DirectAccess: true,
|
||||
Appendix: i18nx.Get("tool.graph.analyzeConversation.appendix"),
|
||||
AppendixKey: "tool.graph.analyzeConversation.appendix",
|
||||
}
|
||||
@@ -92,6 +112,7 @@ var (
|
||||
Description: i18nx.Get("tool.graph.prepareTicketDraft.description"),
|
||||
DescriptionKey: "tool.graph.prepareTicketDraft.description",
|
||||
SourceType: enums.ToolSourceTypeGraph,
|
||||
DirectAccess: true,
|
||||
RuntimeStatic: true,
|
||||
Appendix: i18nx.Get("tool.graph.prepareTicketDraft.appendix"),
|
||||
AppendixKey: "tool.graph.prepareTicketDraft.appendix",
|
||||
@@ -105,7 +126,6 @@ var (
|
||||
Description: i18nx.Get("tool.graph.createTicketConfirm.description"),
|
||||
DescriptionKey: "tool.graph.createTicketConfirm.description",
|
||||
SourceType: enums.ToolSourceTypeGraph,
|
||||
DirectAccess: true,
|
||||
RuntimeStatic: true,
|
||||
Aliases: []string{"builtin/create_ticket_with_confirmation"},
|
||||
Appendix: i18nx.Get("tool.graph.createTicketConfirm.appendix"),
|
||||
@@ -120,7 +140,6 @@ var (
|
||||
Description: i18nx.Get("tool.graph.handoffConversation.description"),
|
||||
DescriptionKey: "tool.graph.handoffConversation.description",
|
||||
SourceType: enums.ToolSourceTypeGraph,
|
||||
DirectAccess: true,
|
||||
RuntimeStatic: true,
|
||||
Appendix: i18nx.Get("tool.graph.handoffConversation.appendix"),
|
||||
AppendixKey: "tool.graph.handoffConversation.appendix",
|
||||
@@ -128,6 +147,8 @@ var (
|
||||
RegisteredToolSpecs = []ToolSpec{
|
||||
BuiltinToolSearch,
|
||||
BuiltinSkill,
|
||||
BuiltinConversationContext,
|
||||
BuiltinKnowledgeRetrieve,
|
||||
GraphTriageServiceRequest,
|
||||
GraphAnalyzeConversation,
|
||||
GraphPrepareTicketDraft,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
func (r *conversationInterruptRepository) FindByAgentRunIDs(db *gorm.DB, agentRunIDs []int64) []models.ConversationInterrupt {
|
||||
if len(agentRunIDs) == 0 {
|
||||
return []models.ConversationInterrupt{}
|
||||
}
|
||||
var items []models.ConversationInterrupt
|
||||
if err := db.Where("agent_run_id IN ?", agentRunIDs).Find(&items).Error; err != nil {
|
||||
return []models.ConversationInterrupt{}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (r *conversationInterruptRepository) Find(db *gorm.DB, cnd *sqls.Cnd) (list []models.ConversationInterrupt) {
|
||||
cnd.Find(db, &list)
|
||||
return
|
||||
@@ -72,6 +83,8 @@ func (r *conversationInterruptRepository) UpsertByCheckPointID(db *gorm.DB, item
|
||||
columns := map[string]any{
|
||||
"conversation_id": item.ConversationID,
|
||||
"ai_agent_id": item.AIAgentID,
|
||||
"agent_run_id": item.AgentRunID,
|
||||
"agent_step_id": item.AgentStepID,
|
||||
"source_message_id": item.SourceMessageID,
|
||||
"last_resume_message_id": item.LastResumeMessageID,
|
||||
"workflow_run_id": item.WorkflowRunID,
|
||||
|
||||
@@ -77,6 +77,30 @@ func (r *conversationRepository) Count(db *gorm.DB, cnd *sqls.Cnd) int64 {
|
||||
return cnd.Count(db, &models.Conversation{})
|
||||
}
|
||||
|
||||
func (r *conversationRepository) CountByAIAgentID(db *gorm.DB, aiAgentID int64) int64 {
|
||||
query := db.Model(&models.Conversation{})
|
||||
if aiAgentID > 0 {
|
||||
query = query.Where("ai_agent_id = ?", aiAgentID)
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *conversationRepository) CountHandoffByAIAgentID(db *gorm.DB, aiAgentID int64) int64 {
|
||||
query := db.Model(&models.Conversation{}).Where("handoff_at IS NOT NULL")
|
||||
if aiAgentID > 0 {
|
||||
query = query.Where("ai_agent_id = ?", aiAgentID)
|
||||
}
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *conversationRepository) Create(db *gorm.DB, t *models.Conversation) (err error) {
|
||||
err = db.Create(t).Error
|
||||
return
|
||||
|
||||
@@ -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"
|
||||
"time"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
@@ -18,10 +19,13 @@ import (
|
||||
"agent-desk/internal/pkg/httpx/params"
|
||||
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var AIAgentService = newAIAgentService()
|
||||
|
||||
const defaultNewAutonomousRolloutPercent = 5
|
||||
|
||||
func newAIAgentService() *aIAgentService {
|
||||
return &aIAgentService{}
|
||||
}
|
||||
@@ -79,8 +83,11 @@ func (s *aIAgentService) CreateAIAgent(req request.CreateAIAgentRequest, operato
|
||||
if err := repositories.AIAgentRepository.Create(ctx.Tx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
|
||||
return err
|
||||
if item.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
_, err := AIWorkflowService.createDefaultAgentWorkflow(ctx.Tx, item, operator)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -91,31 +98,48 @@ func (s *aIAgentService) UpdateAIAgent(req request.UpdateAIAgentRequest, operato
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if s.Get(req.ID) == nil {
|
||||
current := s.Get(req.ID)
|
||||
if current == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
item, err := s.buildAIAgentModel(req.ID, req.CreateAIAgentRequest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
columns := map[string]any{
|
||||
"name": item.Name,
|
||||
"description": item.Description,
|
||||
"ai_config_id": item.AIConfigID,
|
||||
"runtime_mode": item.RuntimeMode,
|
||||
"max_steps": item.MaxSteps,
|
||||
"context_window": item.ContextWindow,
|
||||
"tool_policy": item.ToolPolicy,
|
||||
"knowledge_policy": item.KnowledgePolicy,
|
||||
"service_mode": item.ServiceMode,
|
||||
"system_prompt": item.SystemPrompt,
|
||||
"welcome_message": item.WelcomeMessage,
|
||||
"reply_timeout_seconds": item.ReplyTimeoutSeconds,
|
||||
"rollout_percent": item.RolloutPercent,
|
||||
"team_ids": item.TeamIDs,
|
||||
"handoff_mode": item.HandoffMode,
|
||||
"fallback_mode": item.FallbackMode,
|
||||
"fallback_message": item.FallbackMessage,
|
||||
"knowledge_ids": item.KnowledgeIDs,
|
||||
"skill_ids": item.SkillIDs,
|
||||
"allowed_mcp_tools": item.AllowedMCPTools,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
}
|
||||
if item.RolloutPercent != current.RolloutPercent {
|
||||
columns["previous_rollout_percent"] = current.RolloutPercent
|
||||
}
|
||||
if current.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || current.RuntimeMode == enums.AIAgentRuntimeModeHybrid || item.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || item.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
// Draft edits must not silently change the already published autonomous or hybrid
|
||||
// behavior. The operator must explicitly publish the new revision.
|
||||
columns["published_revision_id"] = 0
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(sqls.DB(), req.ID, columns)
|
||||
}
|
||||
|
||||
func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) error {
|
||||
@@ -134,6 +158,133 @@ func (s *aIAgentService) DeleteAIAgent(id int64, operator *dto.AuthPrincipal) er
|
||||
})
|
||||
}
|
||||
|
||||
// PublishAIAgent snapshots a non-workflow Agent before it can receive traffic.
|
||||
func (s *aIAgentService) PublishAIAgent(id int64, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
|
||||
if operator == nil {
|
||||
return nil, errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
var revision *models.AgentRevision
|
||||
err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
return errorsx.InvalidParam("workflow and hybrid agents must publish a workflow version")
|
||||
}
|
||||
if err := s.validatePublishableAgent(ctx.Tx, agent); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
revision, err = AgentRevisionService.PublishSnapshot(ctx.Tx, agent, operator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
|
||||
"published_revision_id": revision.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return revision, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) validatePublishableAgent(db *gorm.DB, agent *models.AIAgent) error {
|
||||
if agent == nil || agent.AIConfigID <= 0 {
|
||||
return errorsx.InvalidParam("ai agent model configuration is required before publishing")
|
||||
}
|
||||
config := repositories.AIConfigRepository.Get(db, agent.AIConfigID)
|
||||
if config == nil || config.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("ai agent model configuration is unavailable")
|
||||
}
|
||||
if _, err := s.normalizeToolPolicy(agent.ToolPolicy); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(agent.AllowedMCPTools) == "" {
|
||||
return nil
|
||||
}
|
||||
var directTools []request.AIAgentMCPToolRequest
|
||||
if err := json.Unmarshal([]byte(agent.AllowedMCPTools), &directTools); err != nil {
|
||||
return errorsx.InvalidParam("ai agent direct tools are invalid")
|
||||
}
|
||||
for _, item := range directTools {
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(item.ToolCode)
|
||||
if err != nil || definition.InputSchema == nil {
|
||||
return errorsx.InvalidParam("ai agent direct tool definition is unavailable")
|
||||
}
|
||||
if definition.RequireConfirmation {
|
||||
return errorsx.InvalidParam("ai agent sensitive direct tools must be executed through a confirmed playbook")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RollbackAIAgent switches an Agent back to a previously published immutable
|
||||
// revision. It never rewrites the historical snapshot itself.
|
||||
func (s *aIAgentService) RollbackAIAgent(id, revisionID int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if id <= 0 || revisionID <= 0 {
|
||||
return errorsx.InvalidParam("agent id and revision id are required")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
revision := repositories.AgentRevisionRepository.Get(ctx.Tx, revisionID)
|
||||
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParam("agent revision does not exist")
|
||||
}
|
||||
updates := map[string]any{
|
||||
"published_revision_id": revision.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if agent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow || agent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
if revision.WorkflowVersionID <= 0 || repositories.AIWorkflowVersionRepository.Get(ctx.Tx, revision.WorkflowVersionID) == nil {
|
||||
return errorsx.InvalidParam("workflow revision does not contain a published workflow version")
|
||||
}
|
||||
updates["workflow_version_id"] = revision.WorkflowVersionID
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, updates)
|
||||
})
|
||||
}
|
||||
|
||||
// RollbackAIAgentRollout restores the prior Agent rollout percentage and
|
||||
// swaps it into history, allowing operators to undo and redo one rollout
|
||||
// change without rewriting an immutable AgentRevision.
|
||||
func (s *aIAgentService) RollbackAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if id <= 0 {
|
||||
return errorsx.InvalidParam("agent id is required")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, id)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if agent.PreviousRolloutPercent < 1 || agent.PreviousRolloutPercent > 100 {
|
||||
return errorsx.InvalidParam("agent rollout has no previous value to restore")
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, agent.ID, map[string]any{
|
||||
"rollout_percent": agent.PreviousRolloutPercent,
|
||||
"previous_rollout_percent": agent.RolloutPercent,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRequest) (*models.AIAgent, error) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
@@ -152,6 +303,28 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
if aiConfig.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0011")
|
||||
}
|
||||
if req.RuntimeMode == "" {
|
||||
req.RuntimeMode = enums.AIAgentRuntimeModeAutonomous
|
||||
}
|
||||
if !enums.IsValidAIAgentRuntimeMode(req.RuntimeMode) {
|
||||
return nil, errorsx.InvalidParam("invalid ai agent runtime mode")
|
||||
}
|
||||
if req.RuntimeMode != enums.AIAgentRuntimeModeWorkflow && req.RuntimeMode != enums.AIAgentRuntimeModeAutonomous && req.RuntimeMode != enums.AIAgentRuntimeModeHybrid {
|
||||
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
|
||||
}
|
||||
if req.MaxSteps == 0 {
|
||||
req.MaxSteps = 6
|
||||
}
|
||||
if req.MaxSteps < 1 || req.MaxSteps > 8 {
|
||||
return nil, errorsx.InvalidParam("ai agent max steps must be between 1 and 8")
|
||||
}
|
||||
if req.ContextWindow < 0 {
|
||||
return nil, errorsx.InvalidParam("ai agent context window must not be negative")
|
||||
}
|
||||
toolPolicy, err := s.normalizeToolPolicy(req.ToolPolicy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !slices.Contains(enums.IMConversationServiceModeValues, req.ServiceMode) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0230")
|
||||
}
|
||||
@@ -175,11 +348,25 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
if req.ReplyTimeoutSeconds < 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0144")
|
||||
}
|
||||
if req.RolloutPercent == 0 {
|
||||
if req.RuntimeMode == enums.AIAgentRuntimeModeAutonomous || req.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
req.RolloutPercent = defaultNewAutonomousRolloutPercent
|
||||
} else {
|
||||
req.RolloutPercent = 100
|
||||
}
|
||||
}
|
||||
if req.RolloutPercent < 1 || req.RolloutPercent > 100 {
|
||||
return nil, errorsx.InvalidParam("ai agent rollout percent must be between 1 and 100")
|
||||
}
|
||||
|
||||
skillIDs, err := s.normalizeSkillIDs(req.SkillIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
knowledgeBaseIDs, err := s.normalizeKnowledgeBaseIDs(req.KnowledgeBaseIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
directTools, err := s.normalizeDirectTools(req.DirectTools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -196,20 +383,93 @@ func (s *aIAgentService) buildAIAgentModel(id int64, req request.CreateAIAgentRe
|
||||
Name: name,
|
||||
Description: strings.TrimSpace(req.Description),
|
||||
AIConfigID: req.AIConfigID,
|
||||
RuntimeMode: req.RuntimeMode,
|
||||
MaxSteps: req.MaxSteps,
|
||||
ContextWindow: req.ContextWindow,
|
||||
ToolPolicy: toolPolicy,
|
||||
KnowledgePolicy: strings.TrimSpace(req.KnowledgePolicy),
|
||||
ServiceMode: req.ServiceMode,
|
||||
SystemPrompt: strings.TrimSpace(req.SystemPrompt),
|
||||
WelcomeMessage: strings.TrimSpace(req.WelcomeMessage),
|
||||
ReplyTimeoutSeconds: req.ReplyTimeoutSeconds,
|
||||
RolloutPercent: req.RolloutPercent,
|
||||
TeamIDs: utils.JoinInt64s(teamIDs),
|
||||
HandoffMode: req.HandoffMode,
|
||||
FallbackMode: req.FallbackMode,
|
||||
FallbackMessage: strings.TrimSpace(req.FallbackMessage),
|
||||
KnowledgeIDs: utils.JoinInt64s(knowledgeBaseIDs),
|
||||
SkillIDs: utils.JoinInt64s(skillIDs),
|
||||
AllowedMCPTools: directToolsJSON,
|
||||
WorkflowVersionID: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type normalizedAIAgentToolPolicy struct {
|
||||
MaxTotalCalls int `json:"maxTotalCalls,omitempty"`
|
||||
MaxArgumentBytes int `json:"maxArgumentBytes,omitempty"`
|
||||
AllowedRiskLevels []string `json:"allowedRiskLevels,omitempty"`
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeToolPolicy(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
policy := normalizedAIAgentToolPolicy{}
|
||||
if err := json.Unmarshal([]byte(raw), &policy); err != nil {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy must be valid JSON")
|
||||
}
|
||||
if policy.MaxTotalCalls < 0 || policy.MaxTotalCalls > 8 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxTotalCalls must be between 1 and 8")
|
||||
}
|
||||
if policy.MaxArgumentBytes < 0 || policy.MaxArgumentBytes > 64*1024 {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy maxArgumentBytes must be between 1 and 65536")
|
||||
}
|
||||
seen := make(map[string]struct{}, len(policy.AllowedRiskLevels))
|
||||
riskLevels := make([]string, 0, len(policy.AllowedRiskLevels))
|
||||
for _, level := range policy.AllowedRiskLevels {
|
||||
level = strings.ToLower(strings.TrimSpace(level))
|
||||
if level == "" {
|
||||
continue
|
||||
}
|
||||
if level != "read" && level != "write" && level != "sensitive" {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy contains an invalid risk level")
|
||||
}
|
||||
if _, exists := seen[level]; exists {
|
||||
continue
|
||||
}
|
||||
seen[level] = struct{}{}
|
||||
riskLevels = append(riskLevels, level)
|
||||
}
|
||||
policy.AllowedRiskLevels = riskLevels
|
||||
data, err := json.Marshal(policy)
|
||||
if err != nil {
|
||||
return "", errorsx.InvalidParam("ai agent tool policy is invalid")
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeKnowledgeBaseIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
for _, id := range input {
|
||||
if id <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[id]; exists {
|
||||
continue
|
||||
}
|
||||
knowledgeBase := KnowledgeBaseService.Get(id)
|
||||
if knowledgeBase == nil || knowledgeBase.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParam("knowledge base is not available")
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ret = append(ret, id)
|
||||
}
|
||||
slices.Sort(ret)
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (s *aIAgentService) normalizeTeamIDs(input []int64) ([]int64, error) {
|
||||
ret := make([]int64, 0, len(input))
|
||||
seen := make(map[int64]struct{})
|
||||
@@ -271,11 +531,17 @@ func (s *aIAgentService) normalizeDirectTools(input []request.AIAgentMCPToolRequ
|
||||
if toolx.IsAutoInjectedToolCode(strings.TrimSpace(normalized.ToolCode)) {
|
||||
continue
|
||||
}
|
||||
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0020")
|
||||
}
|
||||
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
|
||||
return nil, err
|
||||
if spec, registered := toolx.GetRegisteredToolSpec(normalized.ToolCode); registered {
|
||||
if !spec.DirectAccess || spec.AutoInjected || (spec.Code != toolx.BuiltinConversationContext.Code && spec.Code != toolx.BuiltinKnowledgeRetrieve.Code && spec.Code != toolx.GraphTriageServiceRequest.Code && spec.Code != toolx.GraphAnalyzeConversation.Code && spec.Code != toolx.GraphPrepareTicketDraft.Code) {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0020")
|
||||
}
|
||||
} else {
|
||||
if toolx.ResolveToolSourceType(normalized.ToolCode) != enums.ToolSourceTypeMCP {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0020")
|
||||
}
|
||||
if err := ToolCatalogService.ValidateToolCode(normalized.ToolCode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
key := strings.TrimSpace(normalized.ToolCode)
|
||||
if _, exists := seen[key]; exists {
|
||||
|
||||
@@ -12,13 +12,14 @@ import (
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/mlogclub/simple/sqls"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
func TestAIAgentServiceCreatesWorkflowOnlyWhenRequested(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
aiConfigID := createAIAgentWorkflowTestConfig(t)
|
||||
@@ -26,6 +27,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "workflow agent",
|
||||
AIConfigID: aiConfigID,
|
||||
RuntimeMode: enums.AIAgentRuntimeModeWorkflow,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly,
|
||||
HandoffMode: enums.AIAgentHandoffModeWaitPool,
|
||||
FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
@@ -33,6 +35,15 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
if item.RuntimeMode != enums.AIAgentRuntimeModeWorkflow {
|
||||
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeWorkflow)
|
||||
}
|
||||
if item.MaxSteps != 6 {
|
||||
t.Fatalf("default max steps = %d, want 6", item.MaxSteps)
|
||||
}
|
||||
if item.RolloutPercent != 100 {
|
||||
t.Fatalf("workflow rollout default = %d, want 100", item.RolloutPercent)
|
||||
}
|
||||
|
||||
workflow, err := AIWorkflowService.GetOrCreateAgentWorkflow(item.ID, operator)
|
||||
if err != nil {
|
||||
@@ -81,10 +92,12 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeSendReply, "eq", "direct_reply")
|
||||
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypeHandoffToHuman, "eq", "handoff_to_human")
|
||||
assertConditionBranchToNodeID(t, stored, "policy_route_1", "handoff_confirm_prompt_1", "eq", "handoff_to_human")
|
||||
assertConditionBranchToNodeType(t, stored, "policy_route_1", workflowregistry.NodeTypePrepareTicketDraft, "eq", "prepare_ticket")
|
||||
assertConditionBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_confirm_prompt_1", "is_true", nil)
|
||||
assertDefaultBranchToNodeID(t, stored, "ticket_draft_route_1", "ticket_followup_reply_1")
|
||||
assertConditionBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_1", "is_true", nil)
|
||||
assertDefaultBranchToNodeID(t, stored, "handoff_confirm_route_1", "handoff_cancel_reply_1")
|
||||
assertConditionBranchToNodeID(t, stored, "answerability_route_1", "reply_1", "eq", "answerable")
|
||||
assertDefaultBranchToNodeID(t, stored, "answerability_route_1", "fallback_reply_1")
|
||||
if !workflowEdgeExists(stored, "create_ticket_1", "ticket_result_reply_1") {
|
||||
@@ -93,6 +106,7 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
assertConditionBranchesHavePortEdges(t, stored, "policy_route_1")
|
||||
assertConditionBranchesHavePortEdges(t, stored, "ticket_draft_route_1")
|
||||
assertConditionBranchesHavePortEdges(t, stored, "ticket_confirm_route_1")
|
||||
assertConditionBranchesHavePortEdges(t, stored, "handoff_confirm_route_1")
|
||||
assertConditionBranchesHavePortEdges(t, stored, "answerability_route_1")
|
||||
assertConditionBranchOrder(t, stored, "policy_route_1", []string{
|
||||
"handoff",
|
||||
@@ -114,6 +128,230 @@ func TestAIAgentServiceCreatesDefaultWorkflow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAIAgentServiceDefaultsNewAutonomousAgentToSmallRollout(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "small-rollout autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, aiAgentWorkflowTestOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent: %v", err)
|
||||
}
|
||||
if item.RolloutPercent != defaultNewAutonomousRolloutPercent {
|
||||
t.Fatalf("autonomous rollout default = %d, want %d", item.RolloutPercent, defaultNewAutonomousRolloutPercent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceDefaultsToAutonomousWithoutWorkflow(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "default autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t),
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
if item.RuntimeMode != enums.AIAgentRuntimeModeAutonomous {
|
||||
t.Fatalf("default runtime mode = %q, want %q", item.RuntimeMode, enums.AIAgentRuntimeModeAutonomous)
|
||||
}
|
||||
var workflowCount int64
|
||||
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
|
||||
t.Fatalf("count workflows: %v", err)
|
||||
}
|
||||
if workflowCount != 0 {
|
||||
t.Fatalf("default autonomous agent created %d workflows", workflowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceCreatesWorkflowDraftForHybrid(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
item, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "hybrid agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeHybrid,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, aiAgentWorkflowTestOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
var workflowCount int64
|
||||
if err := sqls.DB().Model(&models.AIWorkflow{}).Where("agent_id = ?", item.ID).Count(&workflowCount).Error; err != nil {
|
||||
t.Fatalf("count workflows: %v", err)
|
||||
}
|
||||
if workflowCount != 1 {
|
||||
t.Fatalf("hybrid agent created %d workflows, want 1", workflowCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceNormalizesToolPolicy(t *testing.T) {
|
||||
policy, err := AIAgentService.normalizeToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["READ","read","sensitive"]}`)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeToolPolicy: %v", err)
|
||||
}
|
||||
if !strings.Contains(policy, `"maxTotalCalls":2`) || !strings.Contains(policy, `"allowedRiskLevels":["read","sensitive"]`) {
|
||||
t.Fatalf("unexpected normalized policy: %s", policy)
|
||||
}
|
||||
if _, err := AIAgentService.normalizeToolPolicy(`{"allowedRiskLevels":["admin"]}`); err == nil {
|
||||
t.Fatal("expected invalid risk level error")
|
||||
}
|
||||
if _, err := AIAgentService.normalizeToolPolicy(`not-json`); err == nil {
|
||||
t.Fatal("expected invalid JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceAllowsRegisteredReadDirectTool(t *testing.T) {
|
||||
tools, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinConversationContext.Code}})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeDirectTools: %v", err)
|
||||
}
|
||||
if len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinConversationContext.Code {
|
||||
t.Fatalf("unexpected normalized direct tools: %#v", tools)
|
||||
}
|
||||
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.BuiltinKnowledgeRetrieve.Code}})
|
||||
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.BuiltinKnowledgeRetrieve.Code {
|
||||
t.Fatalf("expected registered knowledge retrieve tool to be allowed, tools=%#v err=%v", tools, err)
|
||||
}
|
||||
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphPrepareTicketDraft.Code}})
|
||||
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphPrepareTicketDraft.Code {
|
||||
t.Fatalf("expected registered ticket draft tool to be allowed, tools=%#v err=%v", tools, err)
|
||||
}
|
||||
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphAnalyzeConversation.Code}})
|
||||
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphAnalyzeConversation.Code {
|
||||
t.Fatalf("expected registered conversation analysis tool to be allowed, tools=%#v err=%v", tools, err)
|
||||
}
|
||||
tools, err = AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphTriageServiceRequest.Code}})
|
||||
if err != nil || len(tools) != 1 || tools[0].ToolCode != toolx.GraphTriageServiceRequest.Code {
|
||||
t.Fatalf("expected registered service triage tool to be allowed, tools=%#v err=%v", tools, err)
|
||||
}
|
||||
if _, err := AIAgentService.normalizeDirectTools([]request.AIAgentMCPToolRequest{{ToolCode: toolx.GraphHandoffConversation.Code}}); err == nil {
|
||||
t.Fatal("expected unsupported graph direct tool to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceRollsBackToOwnPublishedRevision(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
db := sqls.DB()
|
||||
agent := &models.AIAgent{Name: "rollback-agent", Status: enums.StatusOk, RuntimeMode: enums.AIAgentRuntimeModeAutonomous}
|
||||
if err := db.Create(agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create revision: %v", err)
|
||||
}
|
||||
if err := AIAgentService.RollbackAIAgent(agent.ID, revision.ID, aiAgentWorkflowTestOperator()); err != nil {
|
||||
t.Fatalf("RollbackAIAgent: %v", err)
|
||||
}
|
||||
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != revision.ID {
|
||||
t.Fatalf("rollback did not bind revision: %#v", updated)
|
||||
}
|
||||
otherRevision := &models.AgentRevision{AgentID: agent.ID + 1, Revision: 1, Status: enums.StatusOk}
|
||||
if err := db.Create(otherRevision).Error; err != nil {
|
||||
t.Fatalf("create other revision: %v", err)
|
||||
}
|
||||
if err := AIAgentService.RollbackAIAgent(agent.ID, otherRevision.ID, aiAgentWorkflowTestOperator()); err == nil {
|
||||
t.Fatal("expected cross-agent revision rollback rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceRollsBackPreviousRolloutPercent(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
agent := &models.AIAgent{
|
||||
Name: "rollout-agent",
|
||||
Status: enums.StatusOk,
|
||||
RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
RolloutPercent: 20,
|
||||
PreviousRolloutPercent: 100,
|
||||
}
|
||||
if err := sqls.DB().Create(agent).Error; err != nil {
|
||||
t.Fatalf("create agent: %v", err)
|
||||
}
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
|
||||
t.Fatalf("RollbackAIAgentRollout: %v", err)
|
||||
}
|
||||
updated := AIAgentService.Get(agent.ID)
|
||||
if updated == nil || updated.RolloutPercent != 100 || updated.PreviousRolloutPercent != 20 {
|
||||
t.Fatalf("unexpected rollout rollback result: %#v", updated)
|
||||
}
|
||||
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err != nil {
|
||||
t.Fatalf("second RollbackAIAgentRollout: %v", err)
|
||||
}
|
||||
updated = AIAgentService.Get(agent.ID)
|
||||
if updated == nil || updated.RolloutPercent != 20 || updated.PreviousRolloutPercent != 100 {
|
||||
t.Fatalf("unexpected rollout redo result: %#v", updated)
|
||||
}
|
||||
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("previous_rollout_percent", 0).Error; err != nil {
|
||||
t.Fatalf("clear previous rollout: %v", err)
|
||||
}
|
||||
if err := AIAgentService.RollbackAIAgentRollout(agent.ID, operator); err == nil {
|
||||
t.Fatal("expected missing previous rollout to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceUpdateUnpublishesAutonomousAgent(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "autonomous agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err != nil {
|
||||
t.Fatalf("PublishAIAgent() error = %v", err)
|
||||
}
|
||||
if published := AIAgentService.Get(agent.ID); published == nil || published.PublishedRevisionID <= 0 {
|
||||
t.Fatalf("expected published autonomous agent, got %#v", published)
|
||||
}
|
||||
if err := AIAgentService.UpdateAIAgent(request.UpdateAIAgentRequest{ID: agent.ID, CreateAIAgentRequest: request.CreateAIAgentRequest{
|
||||
Name: agent.Name, Description: "changed draft", AIConfigID: agent.AIConfigID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}}, operator); err != nil {
|
||||
t.Fatalf("UpdateAIAgent() error = %v", err)
|
||||
}
|
||||
if updated := AIAgentService.Get(agent.ID); updated == nil || updated.PublishedRevisionID != 0 {
|
||||
t.Fatalf("expected autonomous update to clear published revision, got %#v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceRejectsPublishWithUnavailableModelConfig(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
configID := createAIAgentWorkflowTestConfig(t)
|
||||
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "unavailable model agent", AIConfigID: configID, RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
if err := sqls.DB().Model(&models.AIConfig{}).Where("id = ?", configID).Update("status", enums.StatusDisabled).Error; err != nil {
|
||||
t.Fatalf("disable model config: %v", err)
|
||||
}
|
||||
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil {
|
||||
t.Fatal("expected unavailable model config to reject publishing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentServiceRejectsPublishWithSensitiveDirectTool(t *testing.T) {
|
||||
setupAIAgentWorkflowTestDB(t)
|
||||
operator := aiAgentWorkflowTestOperator()
|
||||
agent, err := AIAgentService.CreateAIAgent(request.CreateAIAgentRequest{
|
||||
Name: "sensitive tool agent", AIConfigID: createAIAgentWorkflowTestConfig(t), RuntimeMode: enums.AIAgentRuntimeModeAutonomous,
|
||||
ServiceMode: enums.IMConversationServiceModeAIOnly, HandoffMode: enums.AIAgentHandoffModeWaitPool, FallbackMode: enums.AIAgentFallbackModeNoAnswer,
|
||||
}, operator)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAIAgent() error = %v", err)
|
||||
}
|
||||
if err := sqls.DB().Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("allowed_mcp_tools", `[{"toolCode":"mcp/demo/write_order"}]`).Error; err != nil {
|
||||
t.Fatalf("set direct tool: %v", err)
|
||||
}
|
||||
if _, err := AIAgentService.PublishAIAgent(agent.ID, operator); err == nil || !strings.Contains(err.Error(), "confirmed playbook") {
|
||||
t.Fatalf("expected sensitive direct tool publish rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetrieveConfiguration(t *testing.T) {
|
||||
definition := AIWorkflowService.DefaultAgentWorkflowDefinition()
|
||||
if definition.SchemaVersion != dsl.SchemaVersion || nodeTypeByID(definition, "start_1") != workflowregistry.NodeTypeStart {
|
||||
@@ -135,6 +373,13 @@ func TestAIWorkflowServiceDefaultAgentWorkflowDefinitionRequiresKnowledgeRetriev
|
||||
if !workflowHasNodeType(definition, workflowregistry.NodeTypeCreateTicket) {
|
||||
t.Fatalf("expected default workflow to include ticket creation node")
|
||||
}
|
||||
if nodeTypeByID(definition, "handoff_confirm_1") != workflowregistry.NodeTypeHumanConfirm {
|
||||
t.Fatalf("expected default workflow handoff path to include human confirmation")
|
||||
}
|
||||
handoff := workflowNodeByID(t, definition, "handoff_1")
|
||||
if nodeID, field, ok := handoff.Data.InputsValues["confirmed"].Ref(); !ok || nodeID != "handoff_confirm_1" || field != "confirmed" {
|
||||
t.Fatalf("expected handoff to use confirmation result, got %#v", handoff.Data.InputsValues["confirmed"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServiceDefaultAgentWorkflowTicketPromptIncludesDraftFields(t *testing.T) {
|
||||
@@ -203,6 +448,19 @@ func TestAIWorkflowServicePublishAgentWorkflowBindsAgentVersion(t *testing.T) {
|
||||
if storedAgent.WorkflowVersionID != version.ID {
|
||||
t.Fatalf("expected agent workflow version %d, got %d", version.ID, storedAgent.WorkflowVersionID)
|
||||
}
|
||||
if storedAgent.PublishedRevisionID <= 0 {
|
||||
t.Fatalf("expected published agent revision id, got %d", storedAgent.PublishedRevisionID)
|
||||
}
|
||||
var revision models.AgentRevision
|
||||
if err := sqls.DB().First(&revision, storedAgent.PublishedRevisionID).Error; err != nil {
|
||||
t.Fatalf("load agent revision: %v", err)
|
||||
}
|
||||
if revision.AgentID != agent.ID || revision.WorkflowVersionID != version.ID || revision.Revision != 1 || revision.DefinitionHash == "" {
|
||||
t.Fatalf("unexpected published agent revision: %#v", revision)
|
||||
}
|
||||
if !strings.Contains(revision.Definition, `"modelName":"gpt-test"`) || strings.Contains(revision.Definition, "revision-test-secret") {
|
||||
t.Fatalf("unexpected revision definition: %s", revision.Definition)
|
||||
}
|
||||
}
|
||||
|
||||
func setupAIAgentWorkflowTestDB(t *testing.T) {
|
||||
@@ -211,7 +469,7 @@ func setupAIAgentWorkflowTestDB(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AIConfig{}, &models.KnowledgeBase{}, &models.AIWorkflow{}, &models.AIWorkflowVersion{}, &models.AgentRevision{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
@@ -222,6 +480,7 @@ func createAIAgentWorkflowTestConfig(t *testing.T) int64 {
|
||||
item := &models.AIConfig{
|
||||
Name: "workflow-test-config",
|
||||
Provider: enums.AIProviderOpenAI,
|
||||
APIKey: "revision-test-secret",
|
||||
ModelType: enums.AIModelTypeLLM,
|
||||
ModelName: "gpt-test",
|
||||
Status: enums.StatusOk,
|
||||
|
||||
@@ -42,6 +42,13 @@ type AIWorkflowRunAuditItem struct {
|
||||
Agent *models.AIAgent
|
||||
}
|
||||
|
||||
type AIWorkflowTemplate struct {
|
||||
Code string
|
||||
Name string
|
||||
Description string
|
||||
Definition dsl.Definition
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) Get(id int64) *models.AIWorkflow {
|
||||
if id <= 0 {
|
||||
return nil
|
||||
@@ -194,6 +201,46 @@ func (s *aiWorkflowService) DefaultAgentWorkflowDefinition() dsl.Definition {
|
||||
return defaultAgentWorkflowDefinition()
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ListPlaybookTemplates() []AIWorkflowTemplate {
|
||||
return []AIWorkflowTemplate{
|
||||
{Code: "ticket-with-confirmation", Name: "创建工单", Description: "整理工单草稿,经客户确认后创建工单。", Definition: ticketWithConfirmationPlaybookDefinition()},
|
||||
{Code: "identity-confirmation", Name: "身份确认", Description: "在执行后续业务前收集客户的明确确认。", Definition: identityConfirmationPlaybookDefinition()},
|
||||
{Code: "complaint-escalation", Name: "投诉升级", Description: "投诉场景经客户确认后转入人工客服处理。", Definition: complaintEscalationPlaybookDefinition()},
|
||||
{Code: "refund-request-preparation", Name: "退款申请准备", Description: "整理退款诉求,确认后转人工继续核验和处理。", Definition: refundRequestPreparationPlaybookDefinition()},
|
||||
}
|
||||
}
|
||||
|
||||
func ticketWithConfirmationPlaybookDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("draft_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 600, 180, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ready_route_1", workflowregistry.NodeTypeCondition, "草稿分流", 1020, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "prompt_1", "draft_1", "ready", "is_true", nil),
|
||||
{ID: "default", Name: "补充信息", TargetNodeID: "followup_1", Default: true},
|
||||
}}),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "建单确认", 1440, 100, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "ticketTitle": dsl.RefValue("draft_1", "title"), "ticketDescription": dsl.RefValue("draft_1", "description")}, map[string]any{"staticReply": "我已整理工单草稿:{{ticketTitle}}。请确认是否创建。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认建单", 1860, 100, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("confirm_route_1", workflowregistry.NodeTypeCondition, "确认分流", 2280, 100, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "create_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_1", Default: true},
|
||||
}}),
|
||||
workflowNode("create_1", workflowregistry.NodeTypeCreateTicket, "创建工单", 2700, 20, map[string]dsl.Value{"ticketDraft": dsl.RefValue("draft_1", "ticketDraft"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("followup_1", workflowregistry.NodeTypeLLMReply, "补充信息", 1440, 330, map[string]dsl.Value{"userMessage": dsl.RefValue("start_1", "userMessage"), "followUpQuestions": dsl.RefValue("draft_1", "followUpQuestions")}, map[string]any{"staticReply": "创建工单前还需要补充:{{followUpQuestions}}"}),
|
||||
workflowNode("cancel_1", workflowregistry.NodeTypeLLMReply, "取消提示", 2700, 200, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消创建工单。"}),
|
||||
workflowNode("send_result_1", workflowregistry.NodeTypeSendReply, "发送建单结果", 3120, 20, workflowInputs("replyText", "create_1", "message"), nil),
|
||||
workflowNode("send_followup_1", workflowregistry.NodeTypeSendReply, "发送补充提示", 1860, 330, workflowInputs("replyText", "followup_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3120, 200, workflowInputs("replyText", "cancel_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 3540, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "draft_1"), workflowEdge("draft_1", "ready_route_1"), workflowPortEdge("ready_route_1", "prompt_1", "ready"), workflowPortEdge("ready_route_1", "followup_1", "default"),
|
||||
workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "confirm_route_1"), workflowPortEdge("confirm_route_1", "create_1", "confirmed"), workflowPortEdge("confirm_route_1", "cancel_1", "default"),
|
||||
workflowEdge("create_1", "send_result_1"), workflowEdge("send_result_1", "end_1"), workflowEdge("followup_1", "send_followup_1"), workflowEdge("send_followup_1", "end_1"), workflowEdge("cancel_1", "send_cancel_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *aiWorkflowService) ValidateDefinition(def dsl.Definition) workflowvalidator.Result {
|
||||
return workflowvalidator.ValidateDefinition(def, s.registry)
|
||||
}
|
||||
@@ -390,11 +437,23 @@ func (s *aiWorkflowService) PublishAgentWorkflow(req request.PublishAIWorkflowRe
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
agent := repositories.AIAgentRepository.Get(ctx.Tx, req.AgentID)
|
||||
if agent == nil {
|
||||
return errorsx.InvalidParamI18n("error.e0002")
|
||||
}
|
||||
if err := AIAgentService.validatePublishableAgent(ctx.Tx, agent); err != nil {
|
||||
return err
|
||||
}
|
||||
revision, err := AgentRevisionService.PublishWorkflowSnapshot(ctx.Tx, agent, version, operator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repositories.AIAgentRepository.Updates(ctx.Tx, req.AgentID, map[string]any{
|
||||
"workflow_version_id": version.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
"workflow_version_id": version.ID,
|
||||
"published_revision_id": revision.ID,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": now,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
@@ -434,7 +493,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
"riskSignals": dsl.RefValue("understanding_1", "riskSignals"),
|
||||
}, nil),
|
||||
workflowNode("policy_route_1", workflowregistry.NodeTypeCondition, "策略分流", 1560, 125.5, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("handoff", "转人工", "handoff_1", "policy_1", "action", "eq", "handoff_to_human"),
|
||||
workflowConditionBranch("handoff", "转人工", "handoff_confirm_prompt_1", "policy_1", "action", "eq", "handoff_to_human"),
|
||||
workflowConditionBranch("direct", "直接回复", "policy_reply_1", "policy_1", "action", "eq", "direct_reply"),
|
||||
workflowConditionBranch("clarify", "追问澄清", "policy_reply_1", "policy_1", "action", "eq", "clarify"),
|
||||
workflowConditionBranch("end_conversation", "结束语", "policy_reply_1", "policy_1", "action", "eq", "end_conversation"),
|
||||
@@ -442,9 +501,20 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
workflowConditionBranch("knowledge", "知识库回复", "retrieve_1", "policy_1", "action", "eq", "retrieve_knowledge"),
|
||||
{ID: "default", Name: "策略兜底", TargetNodeID: "policy_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 2020, 0, workflowInputs("reason", "start_1", "userMessage"), nil),
|
||||
workflowNode("handoff_confirm_prompt_1", workflowregistry.NodeTypeLLMReply, "转人工确认文案", 2020, 0, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "我可以为你转接人工客服处理。请回复“确认”继续转人工,或回复“取消”继续由 AI 协助。"}),
|
||||
workflowNode("handoff_confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认转人工", 2480, 0, workflowInputs("prompt", "handoff_confirm_prompt_1", "replyText"), nil),
|
||||
workflowNode("handoff_confirm_route_1", workflowregistry.NodeTypeCondition, "转人工确认分流", 2940, 0, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "handoff_confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消或未确认", TargetNodeID: "handoff_cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工", 3400, 0, map[string]dsl.Value{
|
||||
"reason": dsl.RefValue("start_1", "userMessage"),
|
||||
"confirmed": dsl.RefValue("handoff_confirm_1", "confirmed"),
|
||||
}, nil),
|
||||
workflowNode("handoff_cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消转人工提示", 3400, 480, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消转人工。你可以继续补充问题,我会继续协助。"}),
|
||||
workflowNode("send_handoff_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 3860, 480, workflowInputs("replyText", "handoff_cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("policy_reply_1", workflowregistry.NodeTypeSendReply, "发送策略回复", 4320, 98.5, workflowInputs("replyText", "policy_1", "replyText"), nil),
|
||||
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 2480, 0, nil, nil),
|
||||
workflowNode("handoff_end_1", workflowregistry.NodeTypeEnd, "结束", 3860, 0, nil, nil),
|
||||
workflowNode("draft_ticket_1", workflowregistry.NodeTypePrepareTicketDraft, "整理工单草稿", 2020, 379, workflowInputs("issue", "start_1", "userMessage"), nil),
|
||||
workflowNode("ticket_draft_route_1", workflowregistry.NodeTypeCondition, "草稿就绪分流", 2480, 329, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("ready", "草稿完整", "ticket_confirm_prompt_1", "draft_ticket_1", "ready", "is_true", nil),
|
||||
@@ -497,7 +567,7 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
workflowEdge("start_1", "understanding_1"),
|
||||
workflowEdge("understanding_1", "policy_1"),
|
||||
workflowEdge("policy_1", "policy_route_1"),
|
||||
workflowPortEdge("policy_route_1", "handoff_1", "handoff"),
|
||||
workflowPortEdge("policy_route_1", "handoff_confirm_prompt_1", "handoff"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "direct"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "clarify"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "end_conversation"),
|
||||
@@ -505,7 +575,13 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
workflowPortEdge("policy_route_1", "retrieve_1", "knowledge"),
|
||||
workflowPortEdge("policy_route_1", "policy_reply_1", "default"),
|
||||
workflowEdge("policy_reply_1", "end_1"),
|
||||
workflowEdge("handoff_confirm_prompt_1", "handoff_confirm_1"),
|
||||
workflowEdge("handoff_confirm_1", "handoff_confirm_route_1"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_1", "confirmed"),
|
||||
workflowPortEdge("handoff_confirm_route_1", "handoff_cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "handoff_end_1"),
|
||||
workflowEdge("handoff_cancel_reply_1", "send_handoff_cancel_1"),
|
||||
workflowEdge("send_handoff_cancel_1", "end_1"),
|
||||
workflowEdge("draft_ticket_1", "ticket_draft_route_1"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_confirm_prompt_1", "ready"),
|
||||
workflowPortEdge("ticket_draft_route_1", "ticket_followup_reply_1", "default"),
|
||||
@@ -531,6 +607,62 @@ func defaultAgentWorkflowDefinition() dsl.Definition {
|
||||
}
|
||||
}
|
||||
|
||||
func identityConfirmationPlaybookDefinition() dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, "身份确认提示", 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "为保护你的账户信息,请确认是否继续身份核验。"}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认身份核验", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "confirmed_reply_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("confirmed_reply_1", workflowregistry.NodeTypeLLMReply, "确认结果", 1860, 100, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已收到确认,人工客服将继续为你核验身份。"}),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": "已取消身份核验。"}),
|
||||
workflowNode("send_confirmed_1", workflowregistry.NodeTypeSendReply, "发送确认结果", 2280, 100, workflowInputs("replyText", "confirmed_reply_1", "replyText"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "confirmed_reply_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("confirmed_reply_1", "send_confirmed_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_confirmed_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func complaintEscalationPlaybookDefinition() dsl.Definition {
|
||||
return confirmationHandoffPlaybookDefinition("投诉升级确认", "我们将把本次投诉升级给人工客服处理。请确认是否继续。", "已为你升级投诉,人工客服会尽快跟进。", "投诉升级已取消。")
|
||||
}
|
||||
|
||||
func confirmationHandoffPlaybookDefinition(title, prompt, confirmedReply, cancelledReply string) dsl.Definition {
|
||||
return dsl.Definition{SchemaVersion: dsl.SchemaVersion,
|
||||
Nodes: []dsl.Node{
|
||||
workflowNode("start_1", workflowregistry.NodeTypeStart, "开始", 180, 180, nil, nil),
|
||||
workflowNode("prompt_1", workflowregistry.NodeTypeLLMReply, title, 600, 180, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": prompt}),
|
||||
workflowNode("confirm_1", workflowregistry.NodeTypeHumanConfirm, "确认升级", 1020, 180, workflowInputs("prompt", "prompt_1", "replyText"), nil),
|
||||
workflowNode("route_1", workflowregistry.NodeTypeCondition, "确认分流", 1440, 180, nil, dsl.ConditionConfig{Branches: []dsl.ConditionBranch{
|
||||
workflowConditionBranch("confirmed", "已确认", "handoff_1", "confirm_1", "confirmed", "is_true", nil),
|
||||
{ID: "default", Name: "取消", TargetNodeID: "cancel_reply_1", Default: true},
|
||||
}}),
|
||||
workflowNode("handoff_1", workflowregistry.NodeTypeHandoffToHuman, "转人工处理", 1860, 100, map[string]dsl.Value{"reason": dsl.RefValue("start_1", "userMessage"), "confirmed": dsl.RefValue("confirm_1", "confirmed")}, nil),
|
||||
workflowNode("cancel_reply_1", workflowregistry.NodeTypeLLMReply, "取消提示", 1860, 280, workflowInputs("userMessage", "start_1", "userMessage"), map[string]any{"staticReply": cancelledReply}),
|
||||
workflowNode("send_handoff_1", workflowregistry.NodeTypeSendReply, "发送升级结果", 2280, 100, workflowInputs("replyText", "handoff_1", "message"), nil),
|
||||
workflowNode("send_cancel_1", workflowregistry.NodeTypeSendReply, "发送取消提示", 2280, 280, workflowInputs("replyText", "cancel_reply_1", "replyText"), nil),
|
||||
workflowNode("end_1", workflowregistry.NodeTypeEnd, "结束", 2700, 180, nil, nil),
|
||||
},
|
||||
Edges: []dsl.Edge{
|
||||
workflowEdge("start_1", "prompt_1"), workflowEdge("prompt_1", "confirm_1"), workflowEdge("confirm_1", "route_1"),
|
||||
workflowPortEdge("route_1", "handoff_1", "confirmed"), workflowPortEdge("route_1", "cancel_reply_1", "default"),
|
||||
workflowEdge("handoff_1", "send_handoff_1"), workflowEdge("cancel_reply_1", "send_cancel_1"), workflowEdge("send_handoff_1", "end_1"), workflowEdge("send_cancel_1", "end_1"),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func refundRequestPreparationPlaybookDefinition() dsl.Definition {
|
||||
return confirmationHandoffPlaybookDefinition("退款申请确认", "我会先整理退款申请并转交人工客服核验。请确认是否继续。", "退款申请已准备完成,人工客服将继续核验订单和退款条件。", "退款申请准备已取消。")
|
||||
}
|
||||
|
||||
func workflowNode(id string, nodeType string, title string, x float64, y float64, inputs map[string]dsl.Value, config any) dsl.Node {
|
||||
return dsl.Node{
|
||||
ID: id,
|
||||
|
||||
@@ -83,6 +83,26 @@ func TestAIWorkflowServicePublishCreatesImmutableVersion(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServicePlaybookTemplatesAreValid(t *testing.T) {
|
||||
templates := AIWorkflowService.ListPlaybookTemplates()
|
||||
if len(templates) != 4 {
|
||||
t.Fatalf("template count = %d, want 4", len(templates))
|
||||
}
|
||||
seen := make(map[string]struct{}, len(templates))
|
||||
for _, item := range templates {
|
||||
if item.Code == "" || item.Name == "" {
|
||||
t.Fatalf("template identity is required: %#v", item)
|
||||
}
|
||||
if _, exists := seen[item.Code]; exists {
|
||||
t.Fatalf("duplicate template code: %s", item.Code)
|
||||
}
|
||||
seen[item.Code] = struct{}{}
|
||||
if result := AIWorkflowService.ValidateDefinition(item.Definition); !result.Valid {
|
||||
t.Fatalf("template %s is invalid: %#v", item.Code, result.Errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIWorkflowServicePublishIncrementsVersion(t *testing.T) {
|
||||
setupAIWorkflowTestDB(t)
|
||||
operator := aiWorkflowTestOperator()
|
||||
|
||||
@@ -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 {
|
||||
return err
|
||||
}
|
||||
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, map[string]any{
|
||||
"channel_type": item.ChannelType,
|
||||
"channel_id": item.ChannelID,
|
||||
"ai_agent_id": item.AIAgentID,
|
||||
"name": item.Name,
|
||||
"config_json": item.ConfigJSON,
|
||||
"status": item.Status,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
columns := map[string]any{
|
||||
"channel_type": item.ChannelType,
|
||||
"channel_id": item.ChannelID,
|
||||
"ai_agent_id": item.AIAgentID,
|
||||
"ai_agent_rollout_percent": item.AIAgentRolloutPercent,
|
||||
"name": item.Name,
|
||||
"config_json": item.ConfigJSON,
|
||||
"status": item.Status,
|
||||
"remark": item.Remark,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
if item.AIAgentRolloutPercent != current.AIAgentRolloutPercent {
|
||||
columns["previous_ai_agent_rollout_percent"] = current.AIAgentRolloutPercent
|
||||
}
|
||||
return repositories.ChannelRepository.Updates(sqls.DB(), req.ID, columns)
|
||||
}
|
||||
|
||||
// RollbackChannelAIAgentRollout restores the last channel-level rollout value
|
||||
// and swaps it into history so the action itself is reversible.
|
||||
func (s *channelService) RollbackChannelAIAgentRollout(id int64, operator *dto.AuthPrincipal) error {
|
||||
if operator == nil {
|
||||
return errorsx.UnauthorizedI18n("error.auth.expired")
|
||||
}
|
||||
if id <= 0 {
|
||||
return errorsx.InvalidParam("channel id is required")
|
||||
}
|
||||
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
|
||||
channel := repositories.ChannelRepository.Get(ctx.Tx, id)
|
||||
if channel == nil || channel.Status == enums.StatusDeleted {
|
||||
return errorsx.InvalidParamI18n("error.e0208")
|
||||
}
|
||||
if channel.PreviousAIAgentRolloutPercent < 1 || channel.PreviousAIAgentRolloutPercent > 100 {
|
||||
return errorsx.InvalidParam("channel rollout has no previous value to restore")
|
||||
}
|
||||
return repositories.ChannelRepository.Updates(ctx.Tx, channel.ID, map[string]any{
|
||||
"ai_agent_rollout_percent": channel.PreviousAIAgentRolloutPercent,
|
||||
"previous_ai_agent_rollout_percent": channel.AIAgentRolloutPercent,
|
||||
"update_user_id": operator.UserID,
|
||||
"update_user_name": operator.Username,
|
||||
"updated_at": time.Now(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,12 +426,30 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
|
||||
if req.AIAgentID <= 0 {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0321")
|
||||
}
|
||||
if req.AIAgentRolloutPercent == 0 {
|
||||
req.AIAgentRolloutPercent = 100
|
||||
}
|
||||
if req.AIAgentRolloutPercent < 1 || req.AIAgentRolloutPercent > 100 {
|
||||
return nil, errorsx.InvalidParam("channel ai agent rollout percent must be between 1 and 100")
|
||||
}
|
||||
aiAgent := AIAgentService.Get(req.AIAgentID)
|
||||
if aiAgent == nil || aiAgent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0004")
|
||||
}
|
||||
if aiAgent.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel")
|
||||
if aiAgent.RuntimeMode == "" || aiAgent.RuntimeMode == enums.AIAgentRuntimeModeWorkflow {
|
||||
if aiAgent.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("ai agent workflow must be published before binding channel")
|
||||
}
|
||||
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeAutonomous {
|
||||
if aiAgent.PublishedRevisionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("autonomous ai agent must be published before binding channel")
|
||||
}
|
||||
} else if aiAgent.RuntimeMode == enums.AIAgentRuntimeModeHybrid {
|
||||
if aiAgent.PublishedRevisionID <= 0 || aiAgent.WorkflowVersionID <= 0 {
|
||||
return nil, errorsx.InvalidParam("hybrid ai agent and workflow must be published before binding channel")
|
||||
}
|
||||
} else {
|
||||
return nil, errorsx.InvalidParam("ai agent runtime mode is not available yet")
|
||||
}
|
||||
status := enums.Status(req.Status)
|
||||
if req.Status == 0 {
|
||||
@@ -485,12 +535,13 @@ func (s *channelService) buildChannelModel(id int64, req request.CreateChannelRe
|
||||
}
|
||||
|
||||
return &models.Channel{
|
||||
ChannelType: channelType,
|
||||
ChannelID: channelID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
Name: name,
|
||||
ConfigJSON: configJSON,
|
||||
Status: status,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
ChannelType: channelType,
|
||||
ChannelID: channelID,
|
||||
AIAgentID: req.AIAgentID,
|
||||
AIAgentRolloutPercent: req.AIAgentRolloutPercent,
|
||||
Name: name,
|
||||
ConfigJSON: configJSON,
|
||||
Status: status,
|
||||
Remark: strings.TrimSpace(req.Remark),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -48,6 +48,123 @@ func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) {
|
||||
db := setupChannelServiceTestDB(t)
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 25,
|
||||
Name: "灰度渠道", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err != nil || item == nil || item.AIAgentRolloutPercent != 25 {
|
||||
t.Fatalf("expected persisted rollout percent, item=%#v err=%v", item, err)
|
||||
}
|
||||
if _, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 101,
|
||||
Name: "错误灰度渠道", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator()); err == nil {
|
||||
t.Fatal("expected invalid rollout percent to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) {
|
||||
db := setupChannelServiceTestDB(t)
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 20,
|
||||
Name: "渠道灰度回滚", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err != nil {
|
||||
t.Fatalf("create channel: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.Channel{}).Where("id = ?", channel.ID).Update("previous_ai_agent_rollout_percent", 100).Error; err != nil {
|
||||
t.Fatalf("set previous rollout: %v", err)
|
||||
}
|
||||
operator := channelServiceTestOperator()
|
||||
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
|
||||
t.Fatalf("RollbackChannelAIAgentRollout: %v", err)
|
||||
}
|
||||
updated := ChannelService.Get(channel.ID)
|
||||
if updated == nil || updated.AIAgentRolloutPercent != 100 || updated.PreviousAIAgentRolloutPercent != 20 {
|
||||
t.Fatalf("unexpected channel rollout rollback: %#v", updated)
|
||||
}
|
||||
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
|
||||
t.Fatalf("second RollbackChannelAIAgentRollout: %v", err)
|
||||
}
|
||||
updated = ChannelService.Get(channel.ID)
|
||||
if updated == nil || updated.AIAgentRolloutPercent != 20 || updated.PreviousAIAgentRolloutPercent != 100 {
|
||||
t.Fatalf("unexpected channel rollout redo: %#v", updated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelServiceRejectsUnpublishedAutonomousRuntime(t *testing.T) {
|
||||
db := setupChannelServiceTestDB(t)
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("runtime_mode", enums.AIAgentRuntimeModeAutonomous).Error; err != nil {
|
||||
t.Fatalf("set autonomous runtime mode: %v", err)
|
||||
}
|
||||
|
||||
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb,
|
||||
AIAgentID: agent.ID,
|
||||
Name: "官网客服",
|
||||
Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err == nil || !strings.Contains(err.Error(), "must be published") {
|
||||
t.Fatalf("expected unpublished autonomous runtime error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelServiceAcceptsPublishedAutonomousRuntime(t *testing.T) {
|
||||
db := setupChannelServiceTestDB(t)
|
||||
agent := createChannelServiceTestAgent(t, db, 1001)
|
||||
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create agent revision: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
|
||||
"runtime_mode": enums.AIAgentRuntimeModeAutonomous,
|
||||
"published_revision_id": revision.ID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("set autonomous runtime mode: %v", err)
|
||||
}
|
||||
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "自主客服", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err != nil || item == nil {
|
||||
t.Fatalf("create channel for autonomous runtime: item=%#v err=%v", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelServiceRequiresBothHybridPublicationArtifacts(t *testing.T) {
|
||||
db := setupChannelServiceTestDB(t)
|
||||
agent := createChannelServiceTestAgent(t, db, 0)
|
||||
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
|
||||
if err := db.Create(revision).Error; err != nil {
|
||||
t.Fatalf("create agent revision: %v", err)
|
||||
}
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
|
||||
"runtime_mode": enums.AIAgentRuntimeModeHybrid,
|
||||
"published_revision_id": revision.ID,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("set hybrid runtime mode: %v", err)
|
||||
}
|
||||
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err == nil || !strings.Contains(err.Error(), "hybrid ai agent") {
|
||||
t.Fatalf("expected hybrid publication error, got %v", err)
|
||||
}
|
||||
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("workflow_version_id", 1001).Error; err != nil {
|
||||
t.Fatalf("set workflow version: %v", err)
|
||||
}
|
||||
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
|
||||
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服已发布", Status: int(enums.StatusOk),
|
||||
}, channelServiceTestOperator())
|
||||
if err != nil || item == nil {
|
||||
t.Fatalf("create channel for hybrid runtime: item=%#v err=%v", item, err)
|
||||
}
|
||||
}
|
||||
|
||||
func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
|
||||
@@ -66,7 +183,7 @@ func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.Channel{}); err != nil {
|
||||
if err := db.AutoMigrate(&models.AIAgent{}, &models.AgentRevision{}, &models.Channel{}); err != nil {
|
||||
t.Fatalf("auto migrate: %v", err)
|
||||
}
|
||||
sqls.SetDB(db)
|
||||
|
||||
@@ -94,6 +94,8 @@ func (s *conversationInterruptService) mergeForCheckpointUpdate(current, next *m
|
||||
merged := *current
|
||||
merged.ConversationID = current.ConversationID
|
||||
merged.AIAgentID = current.AIAgentID
|
||||
merged.AgentRunID = current.AgentRunID
|
||||
merged.AgentStepID = current.AgentStepID
|
||||
merged.SourceMessageID = current.SourceMessageID
|
||||
merged.LastResumeMessageID = current.LastResumeMessageID
|
||||
merged.WorkflowRunID = current.WorkflowRunID
|
||||
@@ -120,6 +122,8 @@ func (s *conversationInterruptService) mergeForPendingUpdate(current, next *mode
|
||||
merged := *current
|
||||
merged.ConversationID = next.ConversationID
|
||||
merged.AIAgentID = next.AIAgentID
|
||||
merged.AgentRunID = next.AgentRunID
|
||||
merged.AgentStepID = next.AgentStepID
|
||||
merged.SourceMessageID = next.SourceMessageID
|
||||
merged.WorkflowRunID = next.WorkflowRunID
|
||||
merged.WorkflowNodeID = next.WorkflowNodeID
|
||||
|
||||
Reference in New Issue
Block a user