feat: Enhance AI Agent and Channel Management

- Updated labels in the AI Agents dashboard for clarity, changing "流程状态" to "Playbook 状态" and "未发布流程" to "未发布 Playbook".
- Introduced AI Agent rollout percentage management in channel editing, allowing users to set and rollback rollout percentages.
- Added new API endpoints for rolling back AI Agent rollout and fetching agent run metrics.
- Implemented new UI components for displaying agent run details, including status, duration, and input/output tokens.
- Enhanced type definitions for AdminChannel and AIAgent to include rollout percentages and runtime modes.
- Updated navigation to include a section for agent runs.
- Added new translations for agent run features in both English and Chinese.
This commit is contained in:
mlogclub
2026-07-25 12:04:06 +08:00
parent 45741d4032
commit 34051a4631
101 changed files with 8377 additions and 340 deletions
+1 -1
Submodule docs updated: 0818d24796...8e0ae6ac8b
@@ -0,0 +1,106 @@
package runtime
import (
"context"
"strings"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
svc "agent-desk/internal/services"
)
// ApplicationRunInput identifies the persisted inputs for an Agent reply.
// Loading these records here keeps channels and debug adapters independent of
// individual engine requirements.
type ApplicationRunInput struct {
ConversationID int64
MessageID int64
AIAgentID int64
}
type ApplicationResumeInput struct {
ApplicationRunInput
CheckPointID string
ResumeData map[string]string
}
// AgentApplicationService is the single application boundary before engine
// dispatch. It owns persisted input loading and relationship validation; the
// selected Engine remains responsible only for runtime execution.
type AgentApplicationService struct {
runtime *Service
}
var DefaultAgentApplicationService = NewAgentApplicationService()
func NewAgentApplicationService() *AgentApplicationService {
return &AgentApplicationService{runtime: NewService()}
}
func (s *AgentApplicationService) Run(ctx context.Context, input ApplicationRunInput) (*RunResult, error) {
req, err := s.loadRequest(input)
if err != nil {
return nil, err
}
return s.RunPrepared(ctx, req)
}
// RunPrepared is for isolated adapters such as the dashboard debug session.
// Callers are responsible for constructing an ephemeral or already-validated
// request; no persistence side effects are introduced by this boundary.
func (s *AgentApplicationService) RunPrepared(ctx context.Context, req RunInput) (*RunResult, error) {
return s.runtime.Run(ctx, req)
}
func (s *AgentApplicationService) Resume(ctx context.Context, input ApplicationResumeInput) (*RunResult, error) {
req, err := s.loadRequest(input.ApplicationRunInput)
if err != nil {
return nil, err
}
checkPointID := strings.TrimSpace(input.CheckPointID)
interrupt := svc.ConversationInterruptService.GetByCheckPointID(checkPointID)
if interrupt == nil || interrupt.ConversationID != req.Conversation.ID {
return nil, errorsx.InvalidParam("pending conversation interrupt does not exist")
}
if interrupt.AIAgentID > 0 && interrupt.AIAgentID != req.AIAgent.ID {
return nil, errorsx.InvalidParam("interrupt does not belong to agent")
}
return s.ResumePrepared(ctx, ResumeInput{
Conversation: req.Conversation,
UserMessage: req.UserMessage,
AIAgent: req.AIAgent,
AIConfig: req.AIConfig,
CheckPointID: checkPointID,
ResumeData: input.ResumeData,
})
}
func (s *AgentApplicationService) ResumePrepared(ctx context.Context, req ResumeInput) (*RunResult, error) {
return s.runtime.Resume(ctx, req)
}
func (s *AgentApplicationService) loadRequest(input ApplicationRunInput) (RunInput, error) {
if input.ConversationID <= 0 || input.MessageID <= 0 || input.AIAgentID <= 0 {
return RunInput{}, errorsx.InvalidParam("conversation, message and agent are required")
}
conversation := svc.ConversationService.Get(input.ConversationID)
if conversation == nil {
return RunInput{}, errorsx.InvalidParam("conversation does not exist")
}
message := svc.MessageService.Get(input.MessageID)
if message == nil || message.ConversationID != conversation.ID {
return RunInput{}, errorsx.InvalidParam("message does not belong to conversation")
}
agent := svc.AIAgentService.Get(input.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return RunInput{}, errorsx.InvalidParam("ai agent is unavailable")
}
if conversation.AIAgentID > 0 && conversation.AIAgentID != agent.ID {
return RunInput{}, errorsx.InvalidParam("agent does not belong to conversation")
}
config := svc.AIConfigService.Get(agent.AIConfigID)
if config == nil || config.Status != enums.StatusOk {
return RunInput{}, errorsx.InvalidParam("ai config is unavailable")
}
return RunInput{Conversation: *conversation, UserMessage: *message, AIAgent: *agent, AIConfig: *config}, nil
}
@@ -0,0 +1,54 @@
package runtime
import (
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentApplicationServiceLoadsConsistentPersistedRequest(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AIConfig{}, &models.AIAgent{}, &models.Conversation{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
config := &models.AIConfig{Status: enums.StatusOk, ModelName: "test-model"}
if err := db.Create(config).Error; err != nil {
t.Fatalf("create config: %v", err)
}
agent := &models.AIAgent{Name: "agent", Status: enums.StatusOk, AIConfigID: config.ID}
if err := db.Create(agent).Error; err != nil {
t.Fatalf("create agent: %v", err)
}
conversation := &models.Conversation{AIAgentID: agent.ID}
if err := db.Create(conversation).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
message := &models.Message{ConversationID: conversation.ID, SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: "hello"}
if err := db.Create(message).Error; err != nil {
t.Fatalf("create message: %v", err)
}
req, err := NewAgentApplicationService().loadRequest(ApplicationRunInput{ConversationID: conversation.ID, MessageID: message.ID, AIAgentID: agent.ID})
if err != nil {
t.Fatalf("loadRequest: %v", err)
}
if req.Conversation.ID != conversation.ID || req.UserMessage.ID != message.ID || req.AIAgent.ID != agent.ID || req.AIConfig.ID != config.ID {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestAgentApplicationServiceRejectsMismatchedMessage(t *testing.T) {
service := NewAgentApplicationService()
if _, err := service.loadRequest(ApplicationRunInput{ConversationID: 1, MessageID: 0, AIAgentID: 1}); err == nil {
t.Fatal("expected invalid identifiers error")
}
}
@@ -0,0 +1,688 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/runtime/instruction"
"agent-desk/internal/ai/runtime/readtools"
"agent-desk/internal/ai/runtime/retrievers"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/toolx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
// AutonomousEngine is the low-risk, no-flow runtime. It uses bounded model
// turns and exposes configured MCP tools only through the shared Tool Registry.
type AutonomousEngine struct {
chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)
history func(int64, int) []models.Message
retrieve func(context.Context, models.AIAgent, string) (string, int, error)
skillSelect func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error)
toolChat func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
}
func NewAutonomousEngine() *AutonomousEngine {
return &AutonomousEngine{
chat: ai.LLM.ChatWithConfig,
history: func(conversationID int64, limit int) []models.Message {
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, limit, "", "")
return items
},
retrieve: retrieveAutonomousKnowledge,
skillSelect: skills.RuntimeService.Select,
toolChat: ai.LLM.ChatWithTools,
}
}
func newAutonomousEngineWithChat(chat func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error)) *AutonomousEngine {
return &AutonomousEngine{chat: chat}
}
func (e *AutonomousEngine) Code() string {
return EngineCodeAutonomous
}
func (e *AutonomousEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
startedAt := time.Now()
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, "", 0, 0, nil, autonomousSkillContext{}, autonomousResponsePolicy{}, nil, err)
return nil, err
}
req.AIAgent = snapshot.Agent
req.AIConfig = snapshot.AIConfig
skillContext := e.selectSkill(ctx, req)
knowledgeContext, retrieverCount, retrieveErr := e.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
}
userPrompt, historyCount := e.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
var toolCalls []svc.EngineToolCallInput
var result *ai.ChatCompletionResult
agentAllowedTools := autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
allowedTools := agentAllowedTools
if skillContext.Skill != nil {
allowedTools = intersectAutonomousToolCodes(agentAllowedTools, skillContext.AllowedToolCodes)
}
if req.Debug {
// Dashboard debug runs may inspect model and retrieval behavior but must
// not invoke direct MCP tools against production integrations.
allowedTools = nil
}
if responsePolicy.Enforced {
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
} else if len(allowedTools) > 0 && e.toolChat != nil {
loopResult, loopErr := e.toolChat(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{autonomousToolSearchDefinition()}, req.AIAgent.MaxSteps, e.toolSearchExecutor(req.Conversation, req.AIAgent, agentAllowedTools, skillContext.AllowedToolCodes, toolPolicy, &toolCalls))
if loopErr != nil {
if len(toolCalls) == 0 {
err := loopErr
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_loop_error")
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
if result == nil && loopResult != nil {
result = &loopResult.ChatCompletionResult
}
if autonomousHasConsecutiveToolFailures(toolCalls, 2) {
responsePolicy = autonomousToolFailurePolicy(req.AIAgent, "tool_consecutive_failures")
result = &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}
}
} else {
result, err = e.chat(ctx, req.AIConfig, systemPrompt, userPrompt)
}
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
if result == nil || strings.TrimSpace(result.Content) == "" {
err = errorsx.InvalidParam("autonomous engine returned an empty reply")
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
result.Content, err = aitooling.NormalizeCustomerReply(result.Content)
if err != nil {
_, _ = writeAutonomousRun(req, startedAt, nil, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, err)
return nil, err
}
runID, recordErr := writeAutonomousRun(req, startedAt, result, userPrompt, historyCount, retrieverCount, retrieveErr, skillContext, responsePolicy, toolCalls, nil)
if recordErr != nil {
return nil, recordErr
}
trace, _ := json.Marshal(map[string]any{
"engine": EngineCodeAutonomous,
"mode": autonomousExecutionMode(allowedTools),
"historyMessageCount": historyCount,
"retrieverCount": retrieverCount,
"skillID": skillContext.SkillID(),
"skillRouteError": skillContext.ErrorMessage,
"responsePolicyAction": responsePolicy.Action,
"debug": req.Debug,
})
return &Summary{
Status: "completed",
ReplyText: strings.TrimSpace(result.Content),
ModelName: result.ModelName,
PromptTokens: result.PromptTokens,
CompletionTokens: result.CompletionTokens,
HistoryMessageCount: historyCount,
RetrieverCount: retrieverCount,
PlannedSkillID: skillContext.SkillID(),
PlannedSkillName: skillContext.SkillName(),
PlanReason: skillContext.MatchReason,
SkillRouteTrace: skillContext.TraceData,
SkillAllowedToolCodes: append([]string(nil), skillContext.AllowedToolCodes...),
AgentRunID: runID,
HandoffRequested: responsePolicy.RequestHandoff && !req.Debug,
TraceData: string(trace),
}, nil
}
func (e *AutonomousEngine) buildUserPrompt(req Request) (string, int) {
limit := req.AIAgent.ContextWindow
if limit <= 0 {
limit = 12
}
if limit > 20 {
limit = 20
}
items := []models.Message(nil)
if e.history != nil && req.Conversation.ID > 0 {
// The triggering customer message is already persisted in most reply
// paths. Fetch one extra item so it does not consume history capacity.
items = e.history(req.Conversation.ID, limit+1)
}
lines := make([]string, 0, len(items)+2)
for _, item := range items {
if item.ID == req.UserMessage.ID || strings.TrimSpace(item.Content) == "" {
continue
}
role := autonomousMessageRole(item)
if role == "" {
continue
}
lines = append(lines, role+": "+utils.BuildRuntimeMessageText(item.MessageType, item.Content))
}
if len(lines) > limit {
lines = lines[len(lines)-limit:]
}
current := strings.TrimSpace(req.UserMessage.Content)
customerContext := buildAutonomousCustomerContext(req.Conversation)
if len(lines) == 0 && customerContext == "" {
return current, 0
}
parts := make([]string, 0, 3)
if customerContext != "" {
parts = append(parts, "Customer context:\n"+customerContext)
}
if len(lines) > 0 {
parts = append(parts, "Conversation history:\n"+strings.Join(lines, "\n"))
}
parts = append(parts, "Current customer message:\n"+current)
return strings.Join(parts, "\n\n"), len(lines)
}
func buildAutonomousCustomerContext(conversation models.Conversation) string {
parts := make([]string, 0, 2)
if name := strings.TrimSpace(conversation.CustomerName); name != "" {
parts = append(parts, "Customer: "+name)
}
if summary := strings.TrimSpace(conversation.LastMessageSummary); summary != "" {
parts = append(parts, "Recent summary: "+summary)
}
return strings.Join(parts, "\n")
}
func autonomousMessageRole(message models.Message) string {
switch message.SenderType {
case "customer":
return "Customer"
case "ai", "agent":
return "Assistant"
default:
return ""
}
}
func (e *AutonomousEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return nil, errorsx.InvalidParam("autonomous agent has no resumable checkpoint")
}
func (e *AutonomousEngine) retrieveKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
if e.retrieve == nil || len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 {
return "", 0, nil
}
return e.retrieve(ctx, agent, query)
}
type autonomousSkillContext struct {
Skill *models.SkillDefinition
MatchReason string
TraceData string
ErrorMessage string
AllowedToolCodes []string
}
type autonomousResponsePolicy struct {
Enforced bool
Action string
Reason string
ReplyText string
RequestHandoff bool
}
func evaluateAutonomousResponsePolicy(agent models.AIAgent, knowledgeContext string, retrieveErr error) autonomousResponsePolicy {
if len(utils.SplitInt64s(agent.KnowledgeIDs)) == 0 || strings.TrimSpace(knowledgeContext) != "" && retrieveErr == nil {
return autonomousResponsePolicy{}
}
if retrieveErr != nil {
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_retrieve_error")
}
return autonomousKnowledgeFallbackPolicy(agent, "knowledge_evidence_missing")
}
func autonomousKnowledgeFallbackPolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return autonomousResponsePolicy{
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
ReplyText: autonomousKnowledgeFallbackReply(agent),
}
}
return autonomousResponsePolicy{
Enforced: true, Action: "clarify", Reason: reason,
ReplyText: autonomousKnowledgeFallbackReply(agent),
}
}
func autonomousToolFailurePolicy(agent models.AIAgent, reason string) autonomousResponsePolicy {
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return autonomousResponsePolicy{
Enforced: true, Action: "handoff", Reason: reason, RequestHandoff: true,
ReplyText: autonomousToolFailureReply(agent),
}
}
return autonomousResponsePolicy{
Enforced: true, Action: "clarify", Reason: reason,
ReplyText: autonomousToolFailureReply(agent),
}
}
func autonomousToolFailureReply(agent models.AIAgent) string {
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
return reply
}
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return "暂时无法完成所需查询,正在为你转接人工客服。"
}
return "暂时无法完成所需查询,请补充更具体的信息后再试一次。"
}
func autonomousHasConsecutiveToolFailures(calls []svc.EngineToolCallInput, minimum int) bool {
if minimum <= 0 {
return false
}
failures := 0
for index := len(calls) - 1; index >= 0; index-- {
if calls[index].Status != "failed" {
break
}
failures++
}
return failures >= minimum
}
func autonomousKnowledgeFallbackReply(agent models.AIAgent) string {
if reply := strings.TrimSpace(agent.FallbackMessage); reply != "" {
return reply
}
if agent.FallbackMode == 0 || agent.FallbackMode == enums.AIAgentFallbackModeSuggestRetry {
return "当前知识库里没有找到足够明确的信息,你可以换个更具体的问法再试一次。"
}
if agent.FallbackMode == enums.AIAgentFallbackModeHandoff {
return "当前知识库没有足够明确的信息,正在为你转接人工客服。"
}
return "当前知识库暂无明确信息。"
}
func (c autonomousSkillContext) SkillID() int64 {
if c.Skill == nil {
return 0
}
return c.Skill.ID
}
func (c autonomousSkillContext) SkillName() string {
if c.Skill == nil {
return ""
}
return strings.TrimSpace(c.Skill.Name)
}
func (e *AutonomousEngine) selectSkill(ctx context.Context, req Request) autonomousSkillContext {
if e.skillSelect == nil || len(utils.SplitInt64s(req.AIAgent.SkillIDs)) == 0 {
return autonomousSkillContext{}
}
result, err := e.skillSelect(ctx, skills.RuntimeContext{
AIAgent: req.AIAgent, AIConfig: req.AIConfig, UserMessage: req.UserMessage.Content, ConversationID: req.Conversation.ID,
})
ret := autonomousSkillContext{}
if err != nil {
ret.ErrorMessage = err.Error()
return ret
}
if result == nil || result.Plan == nil {
return ret
}
ret.Skill = result.Plan.Skill
ret.MatchReason = strings.TrimSpace(result.Plan.MatchReason)
if result.Trace != nil {
data, _ := json.Marshal(result.Trace)
ret.TraceData = string(data)
}
if ret.Skill != nil {
ret.AllowedToolCodes = parseSkillToolWhitelist(ret.Skill.ToolWhitelist)
}
return ret
}
func parseSkillToolWhitelist(raw string) []string {
var items []string
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
return nil
}
ret := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
if item == "" {
continue
}
if _, exists := seen[item]; exists {
continue
}
seen[item] = struct{}{}
ret = append(ret, item)
}
return ret
}
func intersectAutonomousToolCodes(agentAllowed, skillAllowed []string) []string {
if len(agentAllowed) == 0 || len(skillAllowed) == 0 {
return nil
}
allowed := make(map[string]struct{}, len(skillAllowed))
for _, item := range skillAllowed {
allowed[toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))] = struct{}{}
}
ret := make([]string, 0, len(agentAllowed))
for _, item := range agentAllowed {
item = toolx.NormalizeToolCodeAlias(strings.TrimSpace(item))
if _, ok := allowed[item]; ok {
ret = append(ret, item)
}
}
return ret
}
type autonomousDirectTool struct {
ToolCode string `json:"toolCode"`
}
type autonomousToolSearchRequest struct {
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
type autonomousToolPolicy struct {
MaxTotalCalls int `json:"maxTotalCalls"`
MaxArgumentBytes int `json:"maxArgumentBytes"`
AllowedRiskLevels []string `json:"allowedRiskLevels"`
}
func parseAutonomousToolPolicy(raw string) autonomousToolPolicy {
policy := autonomousToolPolicy{MaxTotalCalls: 3, MaxArgumentBytes: 32 * 1024}
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &policy) != nil {
return policy
}
if policy.MaxTotalCalls <= 0 || policy.MaxTotalCalls > 8 {
policy.MaxTotalCalls = 3
}
if policy.MaxArgumentBytes <= 0 || policy.MaxArgumentBytes > 64*1024 {
policy.MaxArgumentBytes = 32 * 1024
}
return policy
}
func autonomousAllowedMCPToolCodes(raw string) []string {
var items []autonomousDirectTool
if json.Unmarshal([]byte(strings.TrimSpace(raw)), &items) != nil {
return nil
}
ret := make([]string, 0, len(items))
for _, item := range items {
if code := strings.TrimSpace(item.ToolCode); code != "" {
ret = append(ret, code)
}
}
return ret
}
func autonomousToolSearchDefinition() ai.ToolDefinition {
return ai.ToolDefinition{
Name: "tool_search",
Description: "Use a configured read-only tool only when it is needed to answer the customer. Pass the exact allowed toolCode and an arguments object.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"toolCode": map[string]any{"type": "string"},
"arguments": map[string]any{"type": "object"},
},
"required": []string{"toolCode", "arguments"},
},
}
}
func (e *AutonomousEngine) toolSearchExecutor(conversation models.Conversation, agent models.AIAgent, allowedCodes, skillAllowedCodes []string, toolPolicy autonomousToolPolicy, records *[]svc.EngineToolCallInput) ai.ToolCallExecutor {
return func(ctx context.Context, call ai.ToolCall) (string, error) {
startedAt := time.Now()
if call.Name != "tool_search" {
return "", fmt.Errorf("unsupported autonomous tool: %s", call.Name)
}
var req autonomousToolSearchRequest
if err := json.Unmarshal([]byte(call.Arguments), &req); err != nil {
return "", fmt.Errorf("invalid tool_search arguments: %w", err)
}
policy := aitooling.Policy{
AllowedToolCodes: allowedCodes, SkillAllowedToolCodes: skillAllowedCodes, AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
CallCount: autonomousToolCallCount(*records, req.ToolCode),
TotalCallCount: len(*records),
MaxTotalCalls: toolPolicy.MaxTotalCalls,
MaxArgumentBytes: toolPolicy.MaxArgumentBytes,
Confirmed: true, // The Agent's persisted allow-list is the administrator approval boundary.
}
definition, resultPreview, err := executeAutonomousReadTool(ctx, conversation, agent, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
if err != nil && definition.Code == "" {
mcpDefinition, result, mcpErr := aitooling.DefaultMCPExecutor.Execute(ctx, strings.TrimSpace(req.ToolCode), req.Arguments, policy)
definition, err = mcpDefinition, mcpErr
resultPreview = runtimetooling.BuildReducedToolResultSummary(result)
}
durationMS := int(time.Since(startedAt).Milliseconds())
record := svc.EngineToolCallInput{
ToolCode: strings.TrimSpace(req.ToolCode), Status: "completed", ArgumentsPreview: aitooling.SanitizePreview(call.Arguments), DurationMS: durationMS,
}
if definition.Code != "" {
record.ToolCode = definition.Code
record.RiskLevel = definition.RiskLevel
record.RequireConfirm = definition.RequireConfirmation
}
if err != nil {
record.Status = "failed"
record.ErrorMessage = err.Error()
*records = append(*records, record)
return "", err
}
record.ResultPreview = aitooling.SanitizePreview(resultPreview)
*records = append(*records, record)
return record.ResultPreview, nil
}
}
func executeAutonomousReadTool(ctx context.Context, conversation models.Conversation, agent models.AIAgent, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode != toolx.BuiltinConversationContext.Code && toolCode != toolx.BuiltinKnowledgeRetrieve.Code && toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
return aitooling.Definition{}, "", fmt.Errorf("tool is not a built-in read tool")
}
if toolCode == toolx.GraphTriageServiceRequest.Code || toolCode == toolx.GraphAnalyzeConversation.Code || toolCode == toolx.GraphPrepareTicketDraft.Code {
return readtools.ExecuteGraphTool(ctx, conversation, toolCode, arguments, policy)
}
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
if err != nil {
return aitooling.Definition{}, "", err
}
if err := aitooling.DefaultRegistry.Authorize(definition, policy); err != nil {
return definition, "", err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
if toolCode == toolx.BuiltinKnowledgeRetrieve.Code {
query, _ := arguments["query"].(string)
contextText, count, err := retrieveAutonomousKnowledge(ctx, agent, query)
if err != nil {
return definition, "", err
}
result, err := json.Marshal(map[string]any{"query": strings.TrimSpace(query), "resultCount": count, "context": contextText})
return definition, string(result), err
}
result, err := json.Marshal(map[string]any{
"conversationId": conversation.ID,
"customerName": strings.TrimSpace(conversation.CustomerName),
"lastMessageSummary": strings.TrimSpace(conversation.LastMessageSummary),
"currentAssigneeId": conversation.CurrentAssigneeID,
"recentMessages": autonomousToolConversationMessages(conversation.ID),
})
if err != nil {
return definition, "", err
}
return definition, string(result), nil
}
func autonomousToolConversationMessages(conversationID int64) []map[string]string {
if conversationID <= 0 {
return []map[string]string{}
}
items, _, _ := svc.MessageService.FindByConversationIDCursor(conversationID, 0, 6, "", "")
ret := make([]map[string]string, 0, len(items))
for _, item := range items {
role := autonomousMessageRole(item)
content := strings.TrimSpace(utils.BuildRuntimeMessageText(item.MessageType, item.Content))
if role == "" || content == "" {
continue
}
if runes := []rune(content); len(runes) > 240 {
content = string(runes[:240]) + "..."
}
ret = append(ret, map[string]string{"role": role, "content": content})
}
return ret
}
func autonomousToolCallCount(records []svc.EngineToolCallInput, toolCode string) int {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
count := 0
for _, item := range records {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item.ToolCode)) == toolCode {
count++
}
}
return count
}
func retrieveAutonomousKnowledge(ctx context.Context, agent models.AIAgent, query string) (string, int, error) {
retrieved, err := retrievers.NewKnowledgeRetriever(agent, utils.SplitInt64s(agent.KnowledgeIDs)).RetrieveContext(ctx, query)
if err != nil {
return "", 0, err
}
if retrieved == nil {
return "", 0, nil
}
return strings.TrimSpace(retrieved.ContextText), len(retrieved.ContextResults), nil
}
func buildAutonomousSystemPrompt(agent models.AIAgent, hasKnowledgeBase bool, knowledgeContext string, retrieveErr error) string {
prompt := strings.TrimSpace(agent.SystemPrompt)
if prompt == "" {
prompt = "You are a customer service assistant. Answer accurately, ask for clarification when evidence is insufficient, and do not invent facts."
}
if hasKnowledgeBase && strings.TrimSpace(knowledgeContext) == "" {
prompt += "\n\nNo supporting knowledge was retrieved. Do not invent an answer; ask a focused clarification question or offer human handoff."
}
if retrieveErr != nil {
prompt += "\n\nKnowledge retrieval is temporarily unavailable. Do not claim to have verified any policy or factual detail."
}
return prompt
}
func writeAutonomousRun(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount int, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput, cause error) (int64, error) {
endedAt := time.Now()
status := "completed"
errorMessage := ""
outputPreview := ""
promptTokens := 0
completionTokens := 0
if cause != nil {
status = "failed"
errorMessage = cause.Error()
} else if result != nil {
outputPreview = strings.TrimSpace(result.Content)
promptTokens = result.PromptTokens
completionTokens = result.CompletionTokens
}
trace, _ := json.Marshal(map[string]any{"engine": EngineCodeAutonomous, "mode": autonomousExecutionMode(autonomousAllowedMCPToolCodes(req.AIAgent.AllowedMCPTools)), "status": status, "historyMessageCount": historyCount, "retrieverCount": retrieverCount})
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{
ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID,
SourceMessageID: req.UserMessage.ID, EngineCode: EngineCodeAutonomous, Status: status,
PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt,
ErrorMessage: errorMessage, TraceData: string(trace), StepType: "model", StepCode: "chat_completion",
StepInputPreview: strings.TrimSpace(inputPreview), StepOutputPreview: outputPreview,
AdditionalSteps: autonomousAdditionalSteps(req, retrieverCount, retrieveErr, skillContext, responsePolicy),
ToolCalls: toolCalls,
})
return recordErr
})
return runID, err
}
func autonomousExecutionMode(allowedTools []string) string {
if len(allowedTools) > 0 {
return "tool_calling_loop"
}
return "single_model_turn"
}
func autonomousAdditionalSteps(req Request, retrieverCount int, retrieveErr error, skillContext autonomousSkillContext, responsePolicy autonomousResponsePolicy) []svc.EngineStepInput {
steps := make([]svc.EngineStepInput, 0, 3)
if len(utils.SplitInt64s(req.AIAgent.SkillIDs)) > 0 {
status := "completed"
if skillContext.ErrorMessage != "" {
status = "failed"
}
steps = append(steps, svc.EngineStepInput{
StepType: "skill_route", StepCode: "skill_select", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "selected skill: " + skillContext.SkillName(),
ErrorMessage: skillContext.ErrorMessage,
})
}
if len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0 {
status := "completed"
errorMessage := ""
if retrieveErr != nil {
status = "failed"
errorMessage = retrieveErr.Error()
}
steps = append(steps, svc.EngineStepInput{
StepType: "knowledge", StepCode: "knowledge_retrieve", Status: status,
InputPreview: strings.TrimSpace(req.UserMessage.Content), OutputPreview: "retrieved context items: " + strconv.Itoa(retrieverCount), ErrorMessage: errorMessage,
})
}
if responsePolicy.Enforced {
policyCode := "knowledge_evidence"
if strings.HasPrefix(responsePolicy.Reason, "tool_") {
policyCode = "tool_failure"
}
steps = append(steps, svc.EngineStepInput{
StepType: "policy", StepCode: policyCode, Status: "completed",
InputPreview: responsePolicy.Reason, OutputPreview: responsePolicy.Action,
})
}
return steps
}
var _ Engine = (*AutonomousEngine)(nil)
@@ -0,0 +1,102 @@
package einoexperiment
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/runtime/graphs"
)
const confirmationInterruptType = "human_confirm"
// ConfirmationRequest represents a high-risk Eino tool action that must pause
// at AgentDesk's existing conversation-interrupt boundary.
type ConfirmationRequest struct {
InterruptID string
ToolCode string
Prompt string
Arguments map[string]any
}
type confirmationCheckpoint struct {
Version int `json:"version"`
Engine string `json:"engine"`
InterruptID string `json:"interruptId"`
ToolCode string `json:"toolCode"`
Arguments map[string]any `json:"arguments"`
}
// BuildConfirmationResult returns the generic interrupted result consumed by
// replyInterruptService. That service persists ConversationInterrupt from the
// result, so this package remains independent of database writes.
func BuildConfirmationResult(input applicationruntime.RunInput, request ConfirmationRequest) (*applicationruntime.RunResult, error) {
interruptID := strings.TrimSpace(request.InterruptID)
if interruptID == "" {
interruptID = "eino_confirm"
}
toolCode := strings.TrimSpace(request.ToolCode)
if toolCode == "" {
return nil, fmt.Errorf("confirmation tool code is required")
}
prompt := strings.TrimSpace(request.Prompt)
if prompt == "" {
return nil, fmt.Errorf("confirmation prompt is required")
}
checkpointData, err := json.Marshal(confirmationCheckpoint{
Version: 1, Engine: "eino", InterruptID: interruptID, ToolCode: toolCode, Arguments: cloneConfirmationArguments(request.Arguments),
})
if err != nil {
return nil, fmt.Errorf("encode Eino confirmation checkpoint: %w", err)
}
return &applicationruntime.RunResult{
Status: "interrupted",
Interrupted: true,
CheckPointID: confirmationCheckpointID(input, interruptID, toolCode, checkpointData),
CheckPointData: string(checkpointData),
Interrupts: []applicationruntime.InterruptContextSummary{{
Type: confirmationInterruptType, ID: interruptID, InfoPreview: string(mustMarshalConfirmationPrompt(prompt)),
}},
}, nil
}
// ResumeConfirmation reads the generic ResumeInput populated by the existing
// AgentApplicationService and validates that it belongs to this checkpoint.
func ResumeConfirmation(checkPointData string, input applicationruntime.ResumeInput) (string, confirmationCheckpoint, error) {
checkpoint := confirmationCheckpoint{}
if err := json.Unmarshal([]byte(strings.TrimSpace(checkPointData)), &checkpoint); err != nil {
return "", checkpoint, fmt.Errorf("decode Eino confirmation checkpoint: %w", err)
}
if checkpoint.Version != 1 || checkpoint.Engine != "eino" || strings.TrimSpace(checkpoint.InterruptID) == "" || strings.TrimSpace(checkpoint.ToolCode) == "" {
return "", checkpoint, fmt.Errorf("invalid Eino confirmation checkpoint")
}
decision := graphs.ParseConfirmationDecision(strings.TrimSpace(input.ResumeData[checkpoint.InterruptID]))
if decision == "" {
return "", checkpoint, fmt.Errorf("Eino confirmation decision is required")
}
return string(decision), checkpoint, nil
}
func confirmationCheckpointID(input applicationruntime.RunInput, interruptID, toolCode string, data []byte) string {
digest := sha256.Sum256(append([]byte(strings.TrimSpace(toolCode)+":"+strings.TrimSpace(interruptID)+":"), data...))
return fmt.Sprintf("eino:%d:%d:%s", input.Conversation.ID, input.UserMessage.ID, hex.EncodeToString(digest[:8]))
}
func cloneConfirmationArguments(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
ret := make(map[string]any, len(input))
for key, value := range input {
ret[key] = value
}
return ret
}
func mustMarshalConfirmationPrompt(prompt string) []byte {
data, _ := json.Marshal(map[string]string{"message": prompt})
return data
}
@@ -0,0 +1,94 @@
package einoexperiment
import (
"context"
"encoding/json"
"fmt"
"time"
aitooling "agent-desk/internal/ai/tooling"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// ToolHandler is the adapter point from an approved Eino experiment tool to
// AgentDesk business services. Production handlers must still call services,
// never repositories.
type ToolHandler func(ctx context.Context, arguments map[string]any) (string, error)
// ToolTrace is emitted for every guarded invocation. A future Engine adapter
// can translate it into AgentRun tool-call audit records without coupling this
// experiment package to the service layer.
type ToolTrace struct {
ToolCode string
Arguments map[string]any
Status string
Result string
Err error
Duration time.Duration
}
type ToolTraceHook func(ToolTrace)
// GuardedTool adapts an Eino InvokableTool to the shared ToolPolicyGuard. It is
// deliberately generic so Tool Registry semantics are checked before a tool
// handler is invoked.
type GuardedTool struct {
InfoDefinition *schema.ToolInfo
Definition aitooling.Definition
Policy aitooling.Policy
Handler ToolHandler
Trace ToolTraceHook
}
var _ einotool.InvokableTool = (*GuardedTool)(nil)
func (t *GuardedTool) Info(context.Context) (*schema.ToolInfo, error) {
if t == nil || t.InfoDefinition == nil {
return nil, fmt.Errorf("eino experiment tool info is required")
}
return t.InfoDefinition, nil
}
func (t *GuardedTool) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...einotool.Option) (string, error) {
if t == nil || t.Handler == nil {
return "", fmt.Errorf("eino experiment tool handler is required")
}
startedAt := time.Now()
arguments := map[string]any{}
if err := json.Unmarshal([]byte(argumentsInJSON), &arguments); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", fmt.Errorf("decode tool arguments: %w", err)
}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: t.Definition,
Arguments: arguments,
Policy: t.Policy,
}); err != nil {
t.emitTrace(arguments, "failed", "", err, startedAt)
return "", err
}
if t.Definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(t.Definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := t.Handler(ctx, arguments)
status := "completed"
if err != nil {
status = "failed"
}
t.emitTrace(arguments, status, result, err, startedAt)
return result, err
}
func (t *GuardedTool) emitTrace(arguments map[string]any, status, result string, err error, startedAt time.Time) {
if t == nil || t.Trace == nil {
return
}
t.Trace(ToolTrace{
ToolCode: t.Definition.Code, Arguments: arguments, Status: status, Result: result, Err: err,
Duration: time.Since(startedAt),
})
}
@@ -0,0 +1,37 @@
package einoexperiment
import (
"context"
"fmt"
"strings"
"agent-desk/internal/ai/mcps"
runtimetooling "agent-desk/internal/ai/runtime/tooling"
aitooling "agent-desk/internal/ai/tooling"
)
// MCPToolExecutor is the narrow execution boundary used by the Eino
// experiment. The production MCP executor remains responsible for dynamic
// registry resolution, policy enforcement, timeout, and transport lifecycle.
type MCPToolExecutor interface {
Execute(context.Context, string, map[string]any, aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error)
}
// NewMCPToolHandler adapts a dynamically discovered MCP tool to GuardedTool.
// Callers must still configure GuardedTool.Definition and Policy so its
// pre-handler guard provides a deterministic rejection before MCP transport.
func NewMCPToolHandler(executor MCPToolExecutor, toolCode string, policy aitooling.Policy) ToolHandler {
return func(ctx context.Context, arguments map[string]any) (string, error) {
if executor == nil {
return "", fmt.Errorf("eino experiment MCP executor is required")
}
definition, result, err := executor.Execute(ctx, strings.TrimSpace(toolCode), arguments, policy)
if err != nil {
return "", err
}
if definition.Code == "" {
return "", fmt.Errorf("MCP executor returned an empty tool definition")
}
return runtimetooling.BuildReducedToolResultSummary(result), nil
}
}
@@ -0,0 +1,86 @@
// Package einoexperiment contains an isolated Eino ReAct verification path.
// It must not be registered in the production Agent Engine registry.
package einoexperiment
import (
"context"
"fmt"
"strings"
"time"
"agent-desk/internal/models"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/compose"
"github.com/cloudwego/eino/flow/agent/react"
"github.com/cloudwego/eino/schema"
)
// ReActConfig keeps the experiment dependency-injected. The caller owns model
// construction, connection reuse, and all production configuration decisions.
type ReActConfig struct {
Model model.ToolCallingChatModel
Tools []tool.BaseTool
MaxSteps int
}
// NewOpenAICompatibleModel adapts an existing AgentDesk AI configuration to
// Eino's OpenAI-compatible chat model. It is intentionally not wired into any
// production Engine; the experiment owns the adoption decision.
func NewOpenAICompatibleModel(ctx context.Context, config models.AIConfig) (model.ToolCallingChatModel, error) {
if strings.TrimSpace(config.APIKey) == "" || strings.TrimSpace(config.BaseURL) == "" || strings.TrimSpace(config.ModelName) == "" {
return nil, fmt.Errorf("ai config base URL, API key, and model name are required")
}
modelConfig := &einoopenai.ChatModelConfig{
APIKey: strings.TrimSpace(config.APIKey),
BaseURL: strings.TrimSpace(config.BaseURL),
Model: strings.TrimSpace(config.ModelName),
}
if config.TimeoutMS > 0 {
modelConfig.Timeout = time.Duration(config.TimeoutMS) * time.Millisecond
}
if config.MaxOutputTokens > 0 {
maxTokens := config.MaxOutputTokens
modelConfig.MaxCompletionTokens = &maxTokens
}
return einoopenai.NewChatModel(ctx, modelConfig)
}
// NewReAct creates an Eino ReAct agent without registering it with AgentDesk's
// runtime. It is deliberately suitable only for technical verification.
func NewReAct(ctx context.Context, config ReActConfig) (*react.Agent, error) {
if config.Model == nil {
return nil, fmt.Errorf("eino experiment model is required")
}
maxSteps := config.MaxSteps
if maxSteps <= 0 {
maxSteps = 5
}
return react.NewAgent(ctx, &react.AgentConfig{
ToolCallingModel: config.Model,
ToolsConfig: compose.ToolsNodeConfig{Tools: config.Tools},
MaxStep: maxSteps,
})
}
// Run performs one non-streaming experiment. Context cancellation is passed
// directly to Eino and the injected model/tools.
func Run(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.Message, error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Generate(ctx, input)
}
// Stream performs one streaming experiment. The caller must close the returned
// reader after consuming it.
func Stream(ctx context.Context, config ReActConfig, input []*schema.Message) (*schema.StreamReader[*schema.Message], error) {
agent, err := NewReAct(ctx, config)
if err != nil {
return nil, err
}
return agent.Stream(ctx, input)
}
@@ -0,0 +1,376 @@
package einoexperiment
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/ai/mcps"
"agent-desk/internal/ai/runtime/graphs"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type scriptedToolCallingModel struct {
responses []*schema.Message
calls int
err error
block bool
lastInput []*schema.Message
}
type fakeMCPToolExecutor struct {
toolCode string
arguments map[string]any
policy aitooling.Policy
result *mcps.ToolCallResult
err error
}
type concurrentToolCallingModel struct {
calls atomic.Int32
}
var _ model.ToolCallingChatModel = (*concurrentToolCallingModel)(nil)
func (m *concurrentToolCallingModel) Generate(ctx context.Context, _ []*schema.Message, _ ...model.Option) (*schema.Message, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
m.calls.Add(1)
return schema.AssistantMessage("并发调用完成。", nil), nil
}
func (m *concurrentToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *concurrentToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func (e *fakeMCPToolExecutor) Execute(_ context.Context, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, *mcps.ToolCallResult, error) {
e.toolCode = toolCode
e.arguments = arguments
e.policy = policy
return aitooling.Definition{Code: toolCode, RiskLevel: aitooling.RiskLevelSensitive}, e.result, e.err
}
var _ model.ToolCallingChatModel = (*scriptedToolCallingModel)(nil)
func (m *scriptedToolCallingModel) Generate(ctx context.Context, input []*schema.Message, _ ...model.Option) (*schema.Message, error) {
m.lastInput = append([]*schema.Message(nil), input...)
if err := ctx.Err(); err != nil {
return nil, err
}
if m.block {
<-ctx.Done()
return nil, ctx.Err()
}
if m.err != nil {
return nil, m.err
}
if m.calls >= len(m.responses) {
return nil, errors.New("unexpected model call")
}
result := m.responses[m.calls]
m.calls++
return result, nil
}
func (m *scriptedToolCallingModel) Stream(ctx context.Context, input []*schema.Message, opts ...model.Option) (*schema.StreamReader[*schema.Message], error) {
message, err := m.Generate(ctx, input, opts...)
if err != nil {
return nil, err
}
return schema.StreamReaderFromArray([]*schema.Message{message}), nil
}
func (m *scriptedToolCallingModel) WithTools(_ []*schema.ToolInfo) (model.ToolCallingChatModel, error) {
return m, nil
}
func TestRunExecutesGuardedToolThenReturnsFinalAnswer(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "customer_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/customer_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(_ context.Context, arguments map[string]any) (string, error) {
called = arguments["customerId"] == "42"
return "customer: Ada", nil
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "customer_lookup", Arguments: `{"customerId":"42"}`}}}),
schema.AssistantMessage("已找到客户资料。", nil),
}}
result, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 4}, []*schema.Message{schema.UserMessage("查询客户")})
if err != nil {
t.Fatalf("Run: %v", err)
}
if !called || result == nil || result.Content != "已找到客户资料。" || model.calls != 2 {
t.Fatalf("unexpected ReAct result: called=%t result=%#v modelCalls=%d", called, result, model.calls)
}
}
func TestRunInjectsProvidedConversationContext(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("已理解上下文。", nil)}}
input := []*schema.Message{
schema.SystemMessage("你是客服助手,优先引用知识库。"),
schema.UserMessage("我的订单状态如何?"),
}
if _, err := Run(context.Background(), ReActConfig{Model: model}, input); err != nil {
t.Fatalf("Run: %v", err)
}
if len(model.lastInput) != len(input) || model.lastInput[0].Content != input[0].Content || model.lastInput[1].Content != input[1].Content {
t.Fatalf("conversation context was not passed to model: %#v", model.lastInput)
}
}
func TestNewOpenAICompatibleModelValidatesExistingAIConfig(t *testing.T) {
if _, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{}); err == nil {
t.Fatal("expected incomplete AI config error")
}
configured, err := NewOpenAICompatibleModel(context.Background(), models.AIConfig{
BaseURL: "https://api.example.test/v1", APIKey: "test-key", ModelName: "test-model", TimeoutMS: 1200, MaxOutputTokens: 256,
})
if err != nil || configured == nil {
t.Fatalf("expected OpenAI-compatible model adapter, model=%#v err=%v", configured, err)
}
}
func TestGuardedToolRejectsDisallowedPolicyBeforeHandler(t *testing.T) {
called := false
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/customer_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
called = true
return "unexpected", nil
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); err == nil {
t.Fatal("expected policy rejection")
}
if called {
t.Fatal("handler must not run after policy rejection")
}
}
func TestRunPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation, got %v", err)
}
}
func TestRunPropagatesDeadlineDuringModelCall(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
model := &scriptedToolCallingModel{block: true}
if _, err := Run(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected deadline propagation, got %v", err)
}
}
func TestRunPropagatesModelFailure(t *testing.T) {
modelErr := errors.New("model unavailable")
model := &scriptedToolCallingModel{err: modelErr}
if _, err := Run(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, modelErr) {
t.Fatalf("expected model error propagation, got %v", err)
}
}
func TestRunPropagatesToolFailure(t *testing.T) {
toolErr := errors.New("customer service unavailable")
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "failing_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/failing_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/failing_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "", toolErr
},
}
model := &scriptedToolCallingModel{responses: []*schema.Message{
schema.AssistantMessage("", []schema.ToolCall{{ID: "call-1", Type: "function", Function: schema.FunctionCall{Name: "failing_lookup", Arguments: `{}`}}}),
}}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, toolErr) {
t.Fatalf("expected tool error propagation, got %v", err)
}
}
func TestGuardedToolEnforcesTimeout(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "slow_lookup", Desc: "Read customer data"},
Definition: aitooling.Definition{Code: "builtin/slow_lookup", RiskLevel: aitooling.RiskLevelRead, TimeoutMS: 20},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/slow_lookup"}},
Handler: func(ctx context.Context, _ map[string]any) (string, error) {
<-ctx.Done()
return "", ctx.Err()
},
}
if _, err := guardedTool.InvokableRun(context.Background(), `{}`); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected tool timeout, got %v", err)
}
}
func TestGuardedToolEmitsTraceForPolicyFailure(t *testing.T) {
var trace ToolTrace
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "restricted_lookup", Desc: "Read restricted data"},
Definition: aitooling.Definition{Code: "builtin/restricted_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/other_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "unexpected", nil
},
Trace: func(item ToolTrace) { trace = item },
}
if _, err := guardedTool.InvokableRun(context.Background(), `{"customerId":"42"}`); err == nil {
t.Fatal("expected policy rejection")
}
if trace.ToolCode != "builtin/restricted_lookup" || trace.Status != "failed" || trace.Err == nil || trace.Arguments["customerId"] != "42" || trace.Duration < 0 {
t.Fatalf("unexpected trace: %#v", trace)
}
}
func TestMCPToolHandlerUsesSharedExecutorAndReducesResult(t *testing.T) {
executor := &fakeMCPToolExecutor{result: &mcps.ToolCallResult{Content: []mcps.ToolResultContent{{Type: "text", Text: "customer: Ada"}}}}
policy := aitooling.Policy{AllowedToolCodes: []string{"crm/customer_lookup"}, Confirmed: true}
handler := NewMCPToolHandler(executor, "crm/customer_lookup", policy)
result, err := handler(context.Background(), map[string]any{"customerId": "42"})
if err != nil || result != "customer: Ada" {
t.Fatalf("unexpected MCP handler result=%q err=%v", result, err)
}
if executor.toolCode != "crm/customer_lookup" || executor.arguments["customerId"] != "42" || !executor.policy.Confirmed {
t.Fatalf("unexpected MCP execution: %#v", executor)
}
}
func TestRunStopsAtConfiguredMaxSteps(t *testing.T) {
guardedTool := &GuardedTool{
InfoDefinition: &schema.ToolInfo{Name: "loop_lookup", Desc: "Read loop data"},
Definition: aitooling.Definition{Code: "builtin/loop_lookup", RiskLevel: aitooling.RiskLevelRead},
Policy: aitooling.Policy{AllowedToolCodes: []string{"builtin/loop_lookup"}},
Handler: func(context.Context, map[string]any) (string, error) {
return "keep going", nil
},
}
responses := make([]*schema.Message, 8)
for i := range responses {
responses[i] = schema.AssistantMessage("", []schema.ToolCall{{
ID: "loop-call", Type: "function", Function: schema.FunctionCall{Name: "loop_lookup", Arguments: `{}`},
}})
}
model := &scriptedToolCallingModel{responses: responses}
if _, err := Run(context.Background(), ReActConfig{Model: model, Tools: []tool.BaseTool{guardedTool}, MaxSteps: 2}, []*schema.Message{schema.UserMessage("循环查询")}); err == nil {
t.Fatal("expected configured maximum step limit to stop the loop")
}
}
func TestStreamReturnsModelOutput(t *testing.T) {
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("流式回复", nil)}}
stream, err := Stream(context.Background(), ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")})
if err != nil {
t.Fatalf("Stream: %v", err)
}
defer stream.Close()
result, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("ConcatMessageStream: %v", err)
}
if result.Content != "流式回复" {
t.Fatalf("unexpected stream result: %#v", result)
}
}
func TestStreamPropagatesCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
model := &scriptedToolCallingModel{responses: []*schema.Message{schema.AssistantMessage("unused", nil)}}
if _, err := Stream(ctx, ReActConfig{Model: model}, []*schema.Message{schema.UserMessage("查询")}); !errors.Is(err, context.Canceled) {
t.Fatalf("expected stream cancellation, got %v", err)
}
}
func TestRunSupportsConcurrentIndependentCalls(t *testing.T) {
model := &concurrentToolCallingModel{}
const workers = 16
errs := make(chan error, workers)
var group sync.WaitGroup
for range workers {
group.Add(1)
go func() {
defer group.Done()
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, []*schema.Message{schema.UserMessage("并发查询")})
if err != nil {
errs <- err
return
}
if result == nil || result.Content != "并发调用完成。" {
errs <- errors.New("unexpected concurrent result")
}
}()
}
group.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
if model.calls.Load() != workers {
t.Fatalf("model calls = %d, want %d", model.calls.Load(), workers)
}
}
func TestConfirmationBridgeUsesGenericInterruptAndResumeContracts(t *testing.T) {
input := applicationruntime.RunInput{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 22},
}
result, err := BuildConfirmationResult(input, ConfirmationRequest{
InterruptID: "confirm_refund", ToolCode: "graph/create_ticket_with_confirmation", Prompt: "是否确认提交退款工单?",
Arguments: map[string]any{"title": "退款申请"},
})
if err != nil {
t.Fatalf("BuildConfirmationResult: %v", err)
}
if !result.Interrupted || result.Status != "interrupted" || result.CheckPointID == "" || len(result.Interrupts) != 1 || result.Interrupts[0].Type != confirmationInterruptType || result.Interrupts[0].ID != "confirm_refund" {
t.Fatalf("unexpected confirmation result: %#v", result)
}
decision, checkpoint, err := ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "确认"}})
if err != nil || decision != string(graphs.ConfirmationDecisionConfirm) || checkpoint.ToolCode != "graph/create_ticket_with_confirmation" || checkpoint.Arguments["title"] != "退款申请" {
t.Fatalf("unexpected resume bridge decision=%q checkpoint=%#v err=%v", decision, checkpoint, err)
}
decision, _, err = ResumeConfirmation(result.CheckPointData, applicationruntime.ResumeInput{ResumeData: map[string]string{"confirm_refund": "取消"}})
if err != nil || decision != string(graphs.ConfirmationDecisionCancel) {
t.Fatalf("unexpected cancellation decision=%q err=%v", decision, err)
}
}
func BenchmarkRunWithInjectedModel(b *testing.B) {
model := &concurrentToolCallingModel{}
input := []*schema.Message{schema.SystemMessage("你是客服助手。"), schema.UserMessage("查询订单状态")}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
result, err := Run(context.Background(), ReActConfig{Model: model, MaxSteps: 3}, input)
if err != nil || result == nil || result.Content == "" {
b.Fatalf("Run result=%#v err=%v", result, err)
}
}
}
@@ -0,0 +1,141 @@
package einoexperiment
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"agent-desk/internal/bootstrap"
"agent-desk/internal/models"
"agent-desk/internal/pkg/config"
"agent-desk/internal/pkg/enums"
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
einomodel "github.com/cloudwego/eino/components/model"
"github.com/cloudwego/eino/schema"
)
// TestRealOpenAICompatibleEndpoint is intentionally opt-in because it spends
// a small amount of configured model quota. It verifies the production-shaped
// OpenAI-compatible adapter without exposing credentials in test output.
func TestRealOpenAICompatibleEndpoint(t *testing.T) {
if os.Getenv("EINO_EXPERIMENT_REAL") != "1" {
t.Skip("set EINO_EXPERIMENT_REAL=1 to run against the configured endpoint")
}
configPath := strings.TrimSpace(os.Getenv("EINO_EXPERIMENT_CONFIG"))
var err error
if configPath == "" {
configPath, err = findExperimentConfigPath()
if err != nil {
t.Fatal(err)
}
}
workingDir, err := os.Getwd()
if err != nil {
t.Fatalf("get working directory: %v", err)
}
repoRoot := filepath.Dir(filepath.Dir(configPath))
if err := os.Chdir(repoRoot); err != nil {
t.Fatalf("change to config root: %v", err)
}
t.Cleanup(func() { _ = os.Chdir(workingDir) })
cfg, err := config.Load(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
db, err := bootstrap.InitDB(cfg.DB)
if err != nil {
t.Fatalf("open configured database: %v", err)
}
sqlDB, err := db.DB()
if err == nil {
defer sqlDB.Close()
}
var aiConfig models.AIConfig
if err := db.Where("model_type = ? AND status = ?", enums.AIModelTypeLLM, enums.StatusOk).Order("id").First(&aiConfig).Error; err != nil {
t.Fatalf("load enabled LLM config: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(maxInt(aiConfig.TimeoutMS, 30000))*time.Millisecond)
defer cancel()
model, err := NewOpenAICompatibleModel(ctx, aiConfig)
if err != nil {
t.Fatalf("create Eino model adapter: %v", err)
}
input := []*schema.Message{schema.SystemMessage("You are a terse service assistant."), schema.UserMessage("Reply with exactly: OK")}
startedAt := time.Now()
result, err := Run(ctx, ReActConfig{Model: model, MaxSteps: 2}, input)
if err != nil {
t.Fatalf("Eino ReAct request: %v", err)
}
if result == nil || strings.TrimSpace(result.Content) == "" {
t.Fatal("Eino endpoint returned an empty response")
}
if result.ResponseMeta == nil || result.ResponseMeta.Usage == nil {
t.Fatal("Eino endpoint did not return token usage")
}
t.Logf("real endpoint verified: latency=%s promptTokens=%d completionTokens=%d", time.Since(startedAt).Round(time.Millisecond), result.ResponseMeta.Usage.PromptTokens, result.ResponseMeta.Usage.CompletionTokens)
toolModel, err := model.WithTools([]*schema.ToolInfo{{
Name: "eino_echo",
Desc: "Echoes a short input. Always call this tool when asked to verify tool calling.",
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"text": {Type: schema.String, Desc: "Short text to echo", Required: true},
}),
}})
if err != nil {
t.Fatalf("bind Eino tool: %v", err)
}
toolResult, err := toolModel.Generate(ctx, []*schema.Message{schema.UserMessage("Verify tool calling by invoking eino_echo with text OK.")},
einomodel.WithToolChoice(schema.ToolChoiceForced, "eino_echo"),
einoopenai.WithExtraFields(map[string]any{"enable_thinking": false}),
)
if err != nil {
t.Fatalf("real endpoint tool call: %v", err)
}
if toolResult == nil || len(toolResult.ToolCalls) != 1 || toolResult.ToolCalls[0].Function.Name != "eino_echo" {
t.Fatalf("expected one eino_echo tool call, got %#v", toolResult)
}
stream, err := model.Stream(ctx, []*schema.Message{schema.UserMessage("Reply with exactly: STREAM_OK")})
if err != nil {
t.Fatalf("real endpoint stream: %v", err)
}
// ConcatMessageStream consumes and closes the Eino reader. Do not close it
// again here: v0.9.6 treats a second close as a panic.
streamResult, err := schema.ConcatMessageStream(stream)
if err != nil {
t.Fatalf("concat real stream: %v", err)
}
if streamResult == nil || strings.TrimSpace(streamResult.Content) == "" {
t.Fatal("Eino endpoint stream returned an empty response")
}
t.Logf("real endpoint tool and stream verified: toolCalls=%d streamChars=%d", len(toolResult.ToolCalls), len([]rune(streamResult.Content)))
}
func findExperimentConfigPath() (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", err
}
for {
candidate := filepath.Join(dir, "config", "config.yaml")
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}
func maxInt(value, fallback int) int {
if value > 0 {
return value
}
return fallback
}
+58
View File
@@ -0,0 +1,58 @@
package runtime
import (
"context"
"errors"
"strings"
"agent-desk/internal/pkg/errorsx"
)
const (
EngineCodeWorkflow = "workflow"
EngineCodeAutonomous = "autonomous"
)
// Engine executes one Agent Runtime mode. Implementations must keep business
// mutations behind AgentDesk services and return a normalized RunResult.
type Engine interface {
Code() string
Run(ctx context.Context, req RunInput) (*RunResult, error)
Resume(ctx context.Context, req ResumeInput) (*RunResult, error)
}
// EngineRegistry resolves the runtime implementation. Workflow is the default
// until Agent runtime modes are persisted on AIAgent in the next migration.
type EngineRegistry struct {
engines map[string]Engine
}
func NewEngineRegistry(engines ...Engine) *EngineRegistry {
registry := &EngineRegistry{engines: make(map[string]Engine, len(engines))}
for _, engine := range engines {
if engine == nil || strings.TrimSpace(engine.Code()) == "" {
continue
}
registry.engines[strings.TrimSpace(engine.Code())] = engine
}
return registry
}
func NewDefaultEngineRegistry() *EngineRegistry {
return NewEngineRegistry(NewWorkflowEngine(), NewAutonomousEngine(), NewHybridEngine())
}
func (r *EngineRegistry) Resolve(code string) (Engine, error) {
if r == nil {
return nil, errors.New("agent runtime engine registry is not configured")
}
code = strings.TrimSpace(code)
if code == "" {
code = EngineCodeWorkflow
}
engine := r.engines[code]
if engine == nil {
return nil, errorsx.InvalidParam("agent runtime engine does not exist")
}
return engine, nil
}
@@ -0,0 +1,550 @@
package runtime
import (
"context"
"fmt"
"strings"
"testing"
ai "agent-desk/internal/ai"
"agent-desk/internal/ai/skills"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
svc "agent-desk/internal/services"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestServiceDefaultsToWorkflowEngine(t *testing.T) {
service := NewService()
engine, err := service.registry.Resolve("")
if err != nil {
t.Fatalf("resolve default engine: %v", err)
}
if engine.Code() != EngineCodeWorkflow {
t.Fatalf("expected default engine %q, got %q", EngineCodeWorkflow, engine.Code())
}
}
func TestServiceDispatchesRequestedEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "test"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
summary, err := service.Run(context.Background(), RunInput{AIAgent: models.AIAgent{RuntimeMode: enums.AIAgentRuntimeMode(engine.code)}})
if err != nil {
t.Fatalf("run requested engine: %v", err)
}
if !engine.ran || summary == nil || summary.Status != "completed" {
t.Fatalf("unexpected engine dispatch result: engine=%#v summary=%#v", engine, summary)
}
}
func TestEngineContractKeepsLegacyRequestAliasesCompatible(t *testing.T) {
var _ Engine = (*runtimeTestEngine)(nil)
var input Request = RunInput{}
var result Summary = RunResult{Status: "completed"}
if input.Debug || result.Status != "completed" {
t.Fatalf("unexpected compatibility values: input=%#v result=%#v", input, result)
}
}
func TestServiceRejectsUnknownEngine(t *testing.T) {
service := NewServiceWithRegistry(NewEngineRegistry())
if _, err := service.Run(context.Background(), Request{AIAgent: models.AIAgent{RuntimeMode: "missing"}}); err == nil {
t.Fatal("expected unknown engine error")
}
}
func TestAutonomousEngineRecordsPublishedRevisionRun(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 7, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, _ string, prompt string) (*ai.ChatCompletionResult, error) {
receivedPrompt = prompt
return &ai.ChatCompletionResult{Content: "可以协助你处理这个问题。", ModelName: "test-model", PromptTokens: 8, CompletionTokens: 5}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "退款需要先确认订单号。", 1, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "需要帮助"},
AIAgent: models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "保持专业", KnowledgeIDs: "21"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.ReplyText == "" {
t.Fatalf("unexpected summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != EngineCodeAutonomous || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected agent run: %#v", run)
}
if run.PromptTokens != 8 || !strings.Contains(receivedPrompt, "Knowledge evidence") {
t.Fatalf("expected knowledge evidence in prompt, got %q", receivedPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", run.ID).Find(&steps).Error; err != nil || len(steps) != 2 || steps[1].StepType != "knowledge" {
t.Fatalf("expected model and knowledge steps, steps=%#v err=%v", steps, err)
}
}
func TestAutonomousEngineRecordsRejectedReplyAsFailed(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 15, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "token=secret-value"}, nil
})
_, err = engine.Run(context.Background(), Request{UserMessage: models.Message{ID: 2, Content: "help"}, AIAgent: models.AIAgent{ID: 15, PublishedRevisionID: revision.ID}})
if err == nil {
t.Fatal("expected sensitive model reply to be rejected")
}
var run models.AgentRun
if err := db.Last(&run).Error; err != nil || run.Status != "failed" || strings.Contains(run.ErrorMessage, "secret-value") {
t.Fatalf("expected failed audit run, run=%#v err=%v", run, err)
}
}
func TestAutonomousEngineBuildsBoundedConversationContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
engine.history = func(conversationID int64, limit int) []models.Message {
if conversationID != 11 || limit != 3 {
t.Fatalf("unexpected history query: conversation=%d limit=%d", conversationID, limit)
}
return []models.Message{
{ID: 1, SenderType: "customer", MessageType: "text", Content: "之前的问题"},
{ID: 2, SenderType: "ai", MessageType: "text", Content: "之前的答复"},
{ID: 3, SenderType: "customer", MessageType: "text", Content: "当前问题"},
}
}
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{ID: 11}, UserMessage: models.Message{ID: 3, Content: "当前问题", MessageType: "text"},
AIAgent: models.AIAgent{ContextWindow: 2},
})
if count != 2 || !strings.Contains(prompt, "Customer: 之前的问题") || !strings.Contains(prompt, "Assistant: 之前的答复") || strings.Count(prompt, "当前问题") != 1 {
t.Fatalf("unexpected assembled prompt: %q", prompt)
}
}
func TestAutonomousEngineBuildsCustomerContext(t *testing.T) {
engine := newAutonomousEngineWithChat(nil)
prompt, count := engine.buildUserPrompt(Request{
Conversation: models.Conversation{CustomerName: "张三", LastMessageSummary: "已咨询退款条件"},
UserMessage: models.Message{Content: "我要申请退款", MessageType: "text"},
})
if count != 0 || !strings.Contains(prompt, "Customer: 张三") || !strings.Contains(prompt, "Recent summary: 已咨询退款条件") || !strings.Contains(prompt, "Current customer message:\n我要申请退款") {
t.Fatalf("unexpected customer context: %q", prompt)
}
}
func TestAutonomousEngineInjectsSelectedSkillAndRecordsRoute(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 9, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var systemPrompt string
engine := newAutonomousEngineWithChat(func(_ context.Context, _ models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
systemPrompt = system
return &ai.ChatCompletionResult{Content: "我来协助处理退款。", ModelName: "test-model"}, nil
})
engine.skillSelect = func(context.Context, skills.RuntimeContext) (*skills.ExecutionResult, error) {
return &skills.ExecutionResult{Plan: &skills.ExecutionPlan{
Skill: &models.SkillDefinition{ID: 70, Name: "退款处理", Instruction: "先核对订单信息。", Examples: `["我要退款"]`, ToolWhitelist: `["support/order_lookup"]`},
MatchReason: "llm_route",
}, Trace: &skills.ExecutionTrace{Status: "ok", MatchReason: "llm_route"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "我要退款"},
AIAgent: models.AIAgent{ID: 9, PublishedRevisionID: revision.ID, SkillIDs: "70", SystemPrompt: "保持简洁"}, AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.PlannedSkillID != 70 || summary.PlannedSkillName != "退款处理" || summary.PlanReason != "llm_route" {
t.Fatalf("unexpected skill summary: %#v", summary)
}
if !strings.Contains(systemPrompt, "先核对订单信息") || !strings.Contains(systemPrompt, "我要退款") {
t.Fatalf("selected skill was not injected into system prompt: %q", systemPrompt)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 2 || steps[1].StepType != "skill_route" || steps[1].StepCode != "skill_select" {
t.Fatalf("expected model and skill route audit steps, got %#v", steps)
}
}
func TestAutonomousEngineEnforcesKnowledgeFallbackPolicy(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 10, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
chatCalled := false
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
chatCalled = true
return &ai.ChatCompletionResult{Content: "should not be used"}, nil
})
engine.retrieve = func(context.Context, models.AIAgent, string) (string, int, error) {
return "", 0, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "保修多久"},
AIAgent: models.AIAgent{ID: 10, PublishedRevisionID: revision.ID, KnowledgeIDs: "100", FallbackMessage: "请提供产品型号,我再继续查询。"},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if chatCalled || summary.ReplyText != "请提供产品型号,我再继续查询。" {
t.Fatalf("knowledge fallback policy was not enforced: chatCalled=%t summary=%#v", chatCalled, summary)
}
var steps []models.AgentStep
if err := db.Where("agent_run_id = ?", summary.AgentRunID).Find(&steps).Error; err != nil {
t.Fatalf("load steps: %v", err)
}
if len(steps) != 3 || steps[1].StepType != "knowledge" || steps[2].StepType != "policy" || steps[2].StepCode != "knowledge_evidence" {
t.Fatalf("expected model, knowledge and policy steps, got %#v", steps)
}
}
func TestAutonomousEngineDebugRunDoesNotExposeMCPTools(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 11, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(func(context.Context, models.AIConfig, string, string) (*ai.ChatCompletionResult, error) {
return &ai.ChatCompletionResult{Content: "调试回复", ModelName: "test-model"}, nil
})
engine.toolChat = func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
t.Fatal("debug run must not enter tool calling loop")
return nil, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 11, PublishedRevisionID: revision.ID, AllowedMCPTools: `[{"toolCode":"orders/lookup"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"}, Debug: true,
})
if err != nil || summary == nil || summary.ReplyText != "调试回复" {
t.Fatalf("unexpected debug run result: summary=%#v err=%v", summary, err)
}
}
func TestAutonomousEngineUsesPublishedRevisionSnapshot(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 12, Revision: 1, Definition: `{"agent":{"name":"published","aiConfigId":5,"runtimeMode":"autonomous","maxSteps":4,"systemPrompt":"published instruction"},"model":{"configId":5,"provider":"openai","baseUrl":"https://published.example/v1","modelType":"llm","modelName":"published-model","timeoutMs":12000}}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
var receivedSystem string
var receivedConfig models.AIConfig
engine := newAutonomousEngineWithChat(func(_ context.Context, config models.AIConfig, system, _ string) (*ai.ChatCompletionResult, error) {
receivedSystem = system
receivedConfig = config
return &ai.ChatCompletionResult{Content: "published response", ModelName: config.ModelName}, nil
})
_, err = engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "hello"},
AIAgent: models.AIAgent{ID: 12, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction", AIConfigID: 5},
AIConfig: models.AIConfig{ID: 5, APIKey: "rotated-key", ModelName: "draft-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if !strings.Contains(receivedSystem, "published instruction") || strings.Contains(receivedSystem, "draft instruction") {
t.Fatalf("system prompt did not use published snapshot: %q", receivedSystem)
}
if receivedConfig.ModelName != "published-model" || receivedConfig.BaseURL != "https://published.example/v1" || receivedConfig.APIKey != "rotated-key" {
t.Fatalf("model config did not use safe published snapshot: %#v", receivedConfig)
}
}
func TestHybridEngineUsesBoundPlaybookAndRecordsGenericAudit(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 21, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 14, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(_ context.Context, _ models.AIConfig, system, _ string, definitions []ai.ToolDefinition, _ int, _ ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
if !strings.Contains(system, "published hybrid prompt") || len(definitions) != 1 || definitions[0].Name != "run_playbook" {
t.Fatalf("unexpected hybrid model context: system=%q definitions=%#v", system, definitions)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "这是自主回复。", ModelName: "test-model", PromptTokens: 5, CompletionTokens: 4}}, nil
}
summary, err := engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 3, Content: "普通咨询"},
AIAgent: models.AIAgent{ID: 14, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary == nil || summary.AgentRunID <= 0 || summary.WorkflowRunID != 0 || summary.ReplyText != "这是自主回复。" {
t.Fatalf("unexpected hybrid summary: %#v", summary)
}
var run models.AgentRun
if err := db.First(&run, summary.AgentRunID).Error; err != nil {
t.Fatalf("load agent run: %v", err)
}
if run.EngineCode != "hybrid" || run.AgentRevisionID != revision.ID || run.Status != "completed" {
t.Fatalf("unexpected hybrid audit: %#v", run)
}
}
func TestHybridEngineRejectsPlaybookWhenToolPolicyDisallowsWrites(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AIWorkflowVersion{}, &models.AgentRun{}, &models.AgentStep{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
workflowVersion := &models.AIWorkflowVersion{WorkflowID: 22, Version: 1, Status: enums.StatusOk, Definition: `{"schemaVersion":2,"nodes":[{"id":"start_1","type":"start"},{"id":"end_1","type":"end"}],"edges":[{"sourceNodeID":"start_1","targetNodeID":"end_1"}]}`}
if err := db.Create(workflowVersion).Error; err != nil {
t.Fatalf("create workflow version: %v", err)
}
revision := &models.AgentRevision{AgentID: 15, Revision: 1, Status: enums.StatusOk, WorkflowVersionID: workflowVersion.ID, Definition: `{"agent":{"runtimeMode":"hybrid","systemPrompt":"published hybrid prompt","maxSteps":3,"toolPolicy":"{\"allowedRiskLevels\":[\"read\"]}"},"workflowVersionId":1}`}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := NewHybridEngine()
engine.chatWithTools = func(ctx context.Context, _ models.AIConfig, _ string, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
_, err := execute(ctx, ai.ToolCall{Name: "run_playbook", Arguments: fmt.Sprintf(`{"workflowVersionId":%d}`, workflowVersion.ID)})
return nil, err
}
_, err = engine.Run(context.Background(), Request{
UserMessage: models.Message{ID: 4, Content: "请执行受控流程"},
AIAgent: models.AIAgent{ID: 15, RuntimeMode: enums.AIAgentRuntimeModeHybrid, PublishedRevisionID: revision.ID, WorkflowVersionID: workflowVersion.ID},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err == nil || !strings.Contains(err.Error(), "tool risk level is not allowed") {
t.Fatalf("expected tool policy rejection, got %v", err)
}
}
func TestIntersectAutonomousToolCodesUsesSkillWhitelist(t *testing.T) {
got := intersectAutonomousToolCodes([]string{"support/order_lookup", "support/create_ticket"}, []string{"support/order_lookup"})
if len(got) != 1 || got[0] != "support/order_lookup" {
t.Fatalf("intersection = %#v", got)
}
}
func TestParseAutonomousToolPolicyAndPerToolCount(t *testing.T) {
policy := parseAutonomousToolPolicy(`{"maxTotalCalls":2,"maxArgumentBytes":1024,"allowedRiskLevels":["read"]}`)
if policy.MaxTotalCalls != 2 || policy.MaxArgumentBytes != 1024 || len(policy.AllowedRiskLevels) != 1 {
t.Fatalf("unexpected policy: %#v", policy)
}
defaults := parseAutonomousToolPolicy(`{"maxTotalCalls":99,"maxArgumentBytes":999999}`)
if defaults.MaxTotalCalls != 3 || defaults.MaxArgumentBytes != 32*1024 {
t.Fatalf("invalid policy did not fall back to safe limits: %#v", defaults)
}
count := autonomousToolCallCount([]svc.EngineToolCallInput{{ToolCode: "orders/lookup"}, {ToolCode: "orders/other"}, {ToolCode: "orders/lookup"}}, "orders/lookup")
if count != 2 {
t.Fatalf("per-tool count = %d, want 2", count)
}
}
func TestAutonomousResponsePolicyRequestsHandoffOnlyWhenConfigured(t *testing.T) {
handoff := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeHandoff}, "", nil)
if !handoff.Enforced || !handoff.RequestHandoff || handoff.Action != "handoff" {
t.Fatalf("unexpected handoff policy: %#v", handoff)
}
clarify := evaluateAutonomousResponsePolicy(models.AIAgent{KnowledgeIDs: "1", FallbackMode: enums.AIAgentFallbackModeSuggestRetry}, "", nil)
if !clarify.Enforced || clarify.RequestHandoff || clarify.Action != "clarify" {
t.Fatalf("unexpected clarify policy: %#v", clarify)
}
}
func TestAutonomousConversationContextToolUsesRegistryPolicy(t *testing.T) {
definition, result, err := executeAutonomousReadTool(context.Background(), models.Conversation{CustomerName: "张三", LastMessageSummary: "咨询退款"}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelRead}, Confirmed: true,
})
if err != nil || definition.Code != toolx.BuiltinConversationContext.Code || !strings.Contains(result, `"customerName":"张三"`) {
t.Fatalf("unexpected conversation context tool result: definition=%#v result=%q err=%v", definition, result, err)
}
_, _, err = executeAutonomousReadTool(context.Background(), models.Conversation{}, models.AIAgent{}, toolx.BuiltinConversationContext.Code, nil, aitooling.Policy{
AllowedToolCodes: []string{toolx.BuiltinConversationContext.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: true,
})
if err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected read tool risk rejection, got %v", err)
}
}
func TestAutonomousEngineExecutesAndAuditsConversationContextTool(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.Message{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 13, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
if err := db.Create(&models.Message{ConversationID: 1, SenderType: "customer", MessageType: "text", Content: "上一轮问题"}).Error; err != nil {
t.Fatalf("create prior message: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
output, err := execute(ctx, ai.ToolCall{ID: "call-1", Name: "tool_search", Arguments: `{"toolCode":"builtin/conversation_context","arguments":{}}`})
if err != nil || !strings.Contains(output, `"customerName":"张三"`) || !strings.Contains(output, "上一轮问题") {
t.Fatalf("execute tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-2", Name: "tool_search", Arguments: `{"toolCode":"graph/prepare_ticket_draft","arguments":{"issue":"重复扣费"}}`})
if err != nil || !strings.Contains(output, `"title":"重复扣费"`) {
t.Fatalf("execute ticket draft tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-3", Name: "tool_search", Arguments: `{"toolCode":"graph/analyze_conversation","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"userIntent":"ticket_request"`) {
t.Fatalf("execute conversation analysis tool: output=%q err=%v", output, err)
}
output, err = execute(ctx, ai.ToolCall{ID: "call-4", Name: "tool_search", Arguments: `{"toolCode":"graph/triage_service_request","arguments":{"observedIssue":"重复扣费","needTicket":true}}`})
if err != nil || !strings.Contains(output, `"recommendedAction":"prepare_ticket"`) || !strings.Contains(output, `"ticketDraft"`) {
t.Fatalf("execute service triage tool: output=%q err=%v", output, err)
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "已查询到当前会话信息。", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1, CustomerName: "张三", LastMessageSummary: "咨询退款"}, UserMessage: models.Message{ID: 2, Content: "请查一下当前会话"},
AIAgent: models.AIAgent{ID: 13, PublishedRevisionID: revision.ID, ToolPolicy: `{"maxTotalCalls":4}`, AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"},{"toolCode":"graph/prepare_ticket_draft"},{"toolCode":"graph/analyze_conversation"},{"toolCode":"graph/triage_service_request"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
_, _, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 4 || calls[0].ToolCode != toolx.BuiltinConversationContext.Code || calls[1].ToolCode != toolx.GraphPrepareTicketDraft.Code || calls[2].ToolCode != toolx.GraphAnalyzeConversation.Code || calls[3].ToolCode != toolx.GraphTriageServiceRequest.Code || calls[3].Status != "completed" {
t.Fatalf("unexpected tool audit: %#v", calls)
}
}
func TestAutonomousEngineFallsBackAfterConsecutiveToolFailures(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
revision := &models.AgentRevision{AgentID: 14, Revision: 1}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
engine := newAutonomousEngineWithChat(nil)
engine.toolChat = func(ctx context.Context, _ models.AIConfig, _, _ string, _ []ai.ToolDefinition, _ int, execute ai.ToolCallExecutor) (*ai.ToolLoopResult, error) {
for _, callID := range []string{"call-1", "call-2"} {
_, _ = execute(ctx, ai.ToolCall{ID: callID, Name: "tool_search", Arguments: `{"toolCode":"unknown/unsafe","arguments":{}}`})
}
return &ai.ToolLoopResult{ChatCompletionResult: ai.ChatCompletionResult{Content: "model reply should be replaced", ModelName: "test-model"}}, nil
}
summary, err := engine.Run(context.Background(), Request{
Conversation: models.Conversation{ID: 1}, UserMessage: models.Message{ID: 2, Content: "查询订单"},
AIAgent: models.AIAgent{ID: 14, PublishedRevisionID: revision.ID, FallbackMode: enums.AIAgentFallbackModeHandoff, FallbackMessage: "查询暂不可用,正在转人工。", AllowedMCPTools: `[{"toolCode":"builtin/conversation_context"}]`},
AIConfig: models.AIConfig{ModelName: "test-model"},
})
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if summary.ReplyText != "查询暂不可用,正在转人工。" || !summary.HandoffRequested {
t.Fatalf("expected handoff fallback after tool failures, got %#v", summary)
}
_, steps, calls := svc.AgentRunService.GetDetail(summary.AgentRunID)
if len(calls) != 2 || calls[0].Status != "failed" || calls[1].Status != "failed" {
t.Fatalf("expected failed tool audits, got %#v", calls)
}
if len(steps) < 2 || steps[len(steps)-1].StepCode != "tool_failure" || steps[len(steps)-1].OutputPreview != "handoff" {
t.Fatalf("expected tool failure policy audit, got %#v", steps)
}
}
type runtimeTestEngine struct {
code string
ran bool
}
func (e *runtimeTestEngine) Code() string {
return e.code
}
func (e *runtimeTestEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
e.ran = true
return &RunResult{Status: "completed"}, nil
}
func (e *runtimeTestEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
return &RunResult{Status: "completed"}, nil
}
@@ -0,0 +1,147 @@
package runtime
import (
"context"
"encoding/csv"
"strconv"
"strings"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
)
// OfflineEvaluationCase is an isolated customer-service evaluation sample.
// Expectations are intentionally declarative so the same baseline can evolve
// without changing the runner's request contract.
type OfflineEvaluationCase struct {
ID string `json:"id"`
Category string `json:"category"`
Message string `json:"message"`
History []string `json:"history,omitempty"`
Expect map[string]any `json:"expect,omitempty"`
}
type OfflineEvaluationResult struct {
CaseID string `json:"caseId"`
Category string `json:"category"`
EngineCode string `json:"engineCode"`
Passed bool `json:"passed"`
ReplyText string `json:"replyText"`
Interrupted bool `json:"interrupted"`
Error string `json:"error,omitempty"`
Finding string `json:"finding,omitempty"`
}
type OfflineEvaluationReport struct {
EngineCode string `json:"engineCode"`
Total int `json:"total"`
Passed int `json:"passed"`
Results []OfflineEvaluationResult `json:"results"`
}
// OfflineEvaluationRunner executes only isolated Debug requests. The supplied
// runner makes it testable without a real model and lets callers choose an
// explicit Engine implementation for mode comparison.
type OfflineEvaluationRunner struct {
run func(context.Context, RunInput) (*RunResult, error)
}
func NewOfflineEvaluationRunner(run func(context.Context, RunInput) (*RunResult, error)) *OfflineEvaluationRunner {
return &OfflineEvaluationRunner{run: run}
}
func (r *OfflineEvaluationRunner) Run(ctx context.Context, engineCode string, agent models.AIAgent, config models.AIConfig, cases []OfflineEvaluationCase) OfflineEvaluationReport {
report := OfflineEvaluationReport{EngineCode: strings.TrimSpace(engineCode), Results: make([]OfflineEvaluationResult, 0, len(cases))}
for _, item := range cases {
result := OfflineEvaluationResult{CaseID: strings.TrimSpace(item.ID), Category: strings.TrimSpace(item.Category), EngineCode: report.EngineCode}
if r == nil || r.run == nil {
result.Error, result.Finding = "evaluation runner is not configured", "runner_missing"
report.Results = append(report.Results, result)
continue
}
summary, err := r.run(ctx, RunInput{
Conversation: models.Conversation{AIAgentID: agent.ID, LastMessageSummary: strings.Join(item.History, "\n")},
UserMessage: models.Message{SenderType: enums.IMSenderTypeCustomer, MessageType: enums.IMMessageTypeText, Content: strings.TrimSpace(item.Message), RequestID: "offline-eval:" + strings.TrimSpace(item.ID)},
AIAgent: agent,
AIConfig: config,
Debug: true,
})
if err != nil {
result.Error, result.Finding = err.Error(), "engine_error"
report.Results = append(report.Results, result)
continue
}
if summary != nil {
result.ReplyText = strings.TrimSpace(summary.ReplyText)
result.Interrupted = summary.Interrupted
}
result.Passed, result.Finding = evaluateOfflineCase(item.Expect, summary)
if result.Passed {
report.Passed++
}
report.Results = append(report.Results, result)
}
report.Total = len(report.Results)
return report
}
func (r OfflineEvaluationReport) CSV() (string, error) {
var output strings.Builder
writer := csv.NewWriter(&output)
if err := writer.Write([]string{"caseId", "category", "engineCode", "passed", "interrupted", "finding", "error", "replyText"}); err != nil {
return "", err
}
for _, item := range r.Results {
if err := writer.Write([]string{item.CaseID, item.Category, item.EngineCode, strconv.FormatBool(item.Passed), strconv.FormatBool(item.Interrupted), item.Finding, item.Error, item.ReplyText}); err != nil {
return "", err
}
}
writer.Flush()
return output.String(), writer.Error()
}
func evaluateOfflineCase(expect map[string]any, summary *RunResult) (bool, string) {
if summary == nil || strings.TrimSpace(summary.ReplyText) == "" {
return false, "empty_reply"
}
if requiresConfirmation, _ := expect["requiresConfirmation"].(bool); requiresConfirmation && !summary.Interrupted {
return false, "confirmation_not_reached"
}
if maxWrites, ok := evaluationExpectationInt(expect["maxWriteToolCalls"]); ok {
if maxWrites < 0 {
return false, "invalid_expectation"
}
if writeToolCalls(summary) > maxWrites {
return false, "write_tool_limit_exceeded"
}
}
return true, ""
}
func evaluationExpectationInt(value any) (int, bool) {
switch item := value.(type) {
case int:
return item, true
case int64:
return int(item), true
case float64:
return int(item), item == float64(int(item))
default:
return 0, false
}
}
func writeToolCalls(summary *RunResult) int {
if summary == nil {
return 0
}
count := 0
for _, code := range summary.InvokedToolCodes {
switch toolx.NormalizeToolCodeAlias(code) {
case toolx.GraphCreateTicketConfirm.Code, toolx.GraphHandoffConversation.Code:
count++
}
}
return count
}
@@ -0,0 +1,54 @@
package runtime
import (
"context"
"strings"
"testing"
"agent-desk/internal/models"
)
func TestOfflineEvaluationRunnerUsesDebugIsolationAndExportsCSV(t *testing.T) {
var received []RunInput
runner := NewOfflineEvaluationRunner(func(_ context.Context, input RunInput) (*RunResult, error) {
received = append(received, input)
return &RunResult{ReplyText: "已根据知识库回答。"}, nil
})
report := runner.Run(context.Background(), "autonomous", models.AIAgent{ID: 12}, models.AIConfig{ID: 13}, []OfflineEvaluationCase{{ID: "faq", Category: "faq", Message: "保修期多久", History: []string{"客户:你好"}}})
if report.Total != 1 || report.Passed != 1 || len(received) != 1 || !received[0].Debug || received[0].Conversation.ID != 0 || received[0].UserMessage.RequestID != "offline-eval:faq" {
t.Fatalf("unexpected report or input: report=%#v input=%#v", report, received)
}
csv, err := report.CSV()
if err != nil || !strings.Contains(csv, "caseId,category,engineCode") || !strings.Contains(csv, "faq,faq,autonomous,true") {
t.Fatalf("unexpected csv=%q err=%v", csv, err)
}
}
func TestOfflineEvaluationRunnerChecksConfirmationExpectation(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "已转人工"}, nil
})
report := runner.Run(context.Background(), "workflow", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "handoff", Expect: map[string]any{"requiresConfirmation": true}}})
if report.Passed != 0 || report.Results[0].Finding != "confirmation_not_reached" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestOfflineEvaluationRunnerChecksWriteToolLimit(t *testing.T) {
runner := NewOfflineEvaluationRunner(func(context.Context, RunInput) (*RunResult, error) {
return &RunResult{ReplyText: "调试回复", InvokedToolCodes: []string{"graph/handoff_to_human"}}, nil
})
report := runner.Run(context.Background(), "hybrid", models.AIAgent{}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "write", Expect: map[string]any{"maxWriteToolCalls": 0}}})
if report.Passed != 0 || report.Results[0].Finding != "write_tool_limit_exceeded" {
t.Fatalf("unexpected report: %#v", report)
}
}
func TestServiceRunsOfflineEvaluationWithExplicitEngine(t *testing.T) {
engine := &runtimeTestEngine{code: "evaluation"}
service := NewServiceWithRegistry(NewEngineRegistry(engine))
report, err := service.RunOfflineEvaluation(context.Background(), "evaluation", models.AIAgent{RuntimeMode: "workflow"}, models.AIConfig{}, []OfflineEvaluationCase{{ID: "case"}})
if err != nil || !engine.ran || report.EngineCode != "evaluation" || report.Total != 1 {
t.Fatalf("unexpected evaluation report=%#v engine=%#v err=%v", report, engine, err)
}
}
@@ -0,0 +1,221 @@
package runtime
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
ai "agent-desk/internal/ai"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/ai/runtime/instruction"
workflowregistry "agent-desk/internal/ai/workflow/registry"
workflowvalidator "agent-desk/internal/ai/workflow/validator"
"agent-desk/internal/models"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
svc "agent-desk/internal/services"
"github.com/mlogclub/simple/sqls"
)
const hybridPlaybookToolCode = "playbook/run"
// HybridEngine lets the model choose whether to enter the Agent's one bound
// deterministic Playbook. The Playbook itself is always run by WorkflowEngine.
type HybridEngine struct {
chatWithTools func(context.Context, models.AIConfig, string, string, []ai.ToolDefinition, int, ai.ToolCallExecutor) (*ai.ToolLoopResult, error)
autonomous *AutonomousEngine
workflow *WorkflowEngine
}
func NewHybridEngine() *HybridEngine {
return &HybridEngine{
chatWithTools: ai.LLM.ChatWithTools,
autonomous: NewAutonomousEngine(),
workflow: NewWorkflowEngine(),
}
}
func (e *HybridEngine) Code() string {
return "hybrid"
}
func (e *HybridEngine) Run(ctx context.Context, req RunInput) (*RunResult, error) {
startedAt := time.Now()
req.UserMessage.Content = utils.BuildRuntimeMessageText(req.UserMessage.MessageType, req.UserMessage.Content)
snapshot, err := svc.AgentRevisionService.ResolvePublishedSnapshot(req.AIAgent, req.AIConfig)
if err != nil {
return nil, err
}
req.AIAgent, req.AIConfig = snapshot.Agent, snapshot.AIConfig
if req.AIAgent.WorkflowVersionID <= 0 {
return nil, errorsx.InvalidParam("hybrid agent requires a published playbook workflow")
}
workflow, err := resolveAgentWorkflow(req.AIAgent)
if err != nil {
return nil, err
}
if result := workflowvalidator.ValidateDefinition(workflow.Definition, workflowregistry.DefaultRegistry()); !result.Valid {
return nil, errorsx.InvalidParam("hybrid agent playbook validation failed")
}
skillContext := e.autonomous.selectSkill(ctx, req)
knowledgeContext, retrieverCount, retrieveErr := e.autonomous.retrieveKnowledge(ctx, req.AIAgent, req.UserMessage.Content)
responsePolicy := evaluateAutonomousResponsePolicy(req.AIAgent, knowledgeContext, retrieveErr)
if responsePolicy.Enforced {
return writeHybridResult(req, startedAt, &ai.ChatCompletionResult{Content: responsePolicy.ReplyText, ModelName: req.AIConfig.ModelName}, "", 0, retrieverCount, skillContext, nil, responsePolicy, nil)
}
systemPrompt := buildAutonomousSystemPrompt(req.AIAgent, len(utils.SplitInt64s(req.AIAgent.KnowledgeIDs)) > 0, knowledgeContext, retrieveErr)
if skillInstruction := strings.TrimSpace(instruction.BuildSkillDocument(skillContext.Skill, nil)); skillInstruction != "" {
systemPrompt += "\n\nSkill instructions:\n" + skillInstruction
}
systemPrompt += "\n\nWhen a deterministic process is required, use run_playbook. Do not call it for ordinary factual questions."
userPrompt, historyCount := e.autonomous.buildUserPrompt(req)
if knowledgeContext != "" {
userPrompt += "\n\nKnowledge evidence:\n" + knowledgeContext
}
var playbookSummary *Summary
toolCalls := make([]svc.EngineToolCallInput, 0, 1)
toolPolicy := parseAutonomousToolPolicy(req.AIAgent.ToolPolicy)
loop, err := e.chatWithTools(ctx, req.AIConfig, systemPrompt, userPrompt, []ai.ToolDefinition{hybridPlaybookToolDefinition(req.AIAgent.WorkflowVersionID)}, req.AIAgent.MaxSteps, func(ctx context.Context, call ai.ToolCall) (string, error) {
if call.Name != "run_playbook" {
return "", fmt.Errorf("unsupported hybrid tool: %s", call.Name)
}
if len(toolCalls) >= 1 {
return "", fmt.Errorf("playbook call limit reached")
}
workflowVersionID, err := parseHybridPlaybookCall(call.Arguments)
if err != nil {
return "", err
}
if workflowVersionID != req.AIAgent.WorkflowVersionID {
return "", fmt.Errorf("playbook is not allowed")
}
playbookDefinition := aitooling.Definition{Code: hybridPlaybookToolCode, Name: "run_playbook", RiskLevel: aitooling.RiskLevelWrite, RequireConfirmation: true, MaxCallsPerRun: 1}
if err := aitooling.DefaultRegistry.Authorize(playbookDefinition, aitooling.Policy{
AllowedRiskLevels: toolPolicy.AllowedRiskLevels,
CallCount: len(toolCalls),
TotalCallCount: len(toolCalls),
MaxTotalCalls: 1,
Confirmed: true, // Workflow validation guarantees a human-confirm predecessor for high-risk nodes.
}); err != nil {
return "", err
}
callStartedAt := time.Now()
playbookSummary, err = e.workflow.Run(ctx, req)
toolRecord := svc.EngineToolCallInput{ToolCode: hybridPlaybookToolCode, RiskLevel: "write", RequireConfirm: true, ArgumentsPreview: call.Arguments, DurationMS: int(time.Since(callStartedAt).Milliseconds())}
if err != nil {
toolRecord.Status, toolRecord.ErrorMessage = "failed", err.Error()
toolCalls = append(toolCalls, toolRecord)
return "", err
}
toolRecord.Status = "completed"
toolRecord.ResultPreview = fmt.Sprintf("workflowRunId=%d status=%s", playbookSummary.WorkflowRunID, playbookSummary.Status)
toolCalls = append(toolCalls, toolRecord)
data, _ := json.Marshal(map[string]any{"workflowRunId": playbookSummary.WorkflowRunID, "status": playbookSummary.Status, "replyText": playbookSummary.ReplyText, "interrupted": playbookSummary.Interrupted})
return string(data), nil
})
if err != nil {
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
return nil, err
}
if playbookSummary != nil && playbookSummary.Interrupted {
runID, auditErr := writeHybridAudit(req, startedAt, &ai.ChatCompletionResult{Content: playbookSummary.ReplyText, ModelName: playbookSummary.ModelName, PromptTokens: playbookSummary.PromptTokens, CompletionTokens: playbookSummary.CompletionTokens}, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, true, nil)
if auditErr != nil {
return nil, auditErr
}
playbookSummary.AgentRunID = runID
return playbookSummary, nil
}
if loop == nil || strings.TrimSpace(loop.Content) == "" {
err = errorsx.InvalidParam("hybrid engine returned an empty reply")
_, _ = writeHybridAudit(req, startedAt, nil, userPrompt, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, err)
return nil, err
}
return writeHybridResult(req, startedAt, &loop.ChatCompletionResult, userPrompt, historyCount, retrieverCount, skillContext, playbookSummary, responsePolicy, toolCalls)
}
func (e *HybridEngine) Resume(ctx context.Context, req ResumeInput) (*RunResult, error) {
interrupt := svc.ConversationInterruptService.GetByCheckPointID(req.CheckPointID)
summary, err := e.workflow.Resume(ctx, req)
if err != nil || summary == nil || interrupt == nil || interrupt.AgentRunID <= 0 {
return summary, err
}
if err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
return svc.AgentRunService.RecordHybridPlaybookResume(tx.Tx, interrupt.AgentRunID, summary.WorkflowRunID, summary.Status, summary.ReplyText)
}); err != nil {
return nil, err
}
// The resumed WorkflowRun is a child audit artifact. Keep the original
// Hybrid run as the summary run surfaced to the conversation caller.
summary.AgentRunID = interrupt.AgentRunID
return summary, nil
}
func hybridPlaybookToolDefinition(workflowVersionID int64) ai.ToolDefinition {
return ai.ToolDefinition{Name: "run_playbook", Description: "Run the Agent's published deterministic Playbook when the customer needs a controlled business action.", Parameters: map[string]any{
"type": "object", "properties": map[string]any{"workflowVersionId": map[string]any{"type": "integer", "description": "The bound Playbook version."}}, "required": []string{"workflowVersionId"},
}}
}
func parseHybridPlaybookCall(raw string) (int64, error) {
var input struct {
WorkflowVersionID int64 `json:"workflowVersionId"`
}
if err := json.Unmarshal([]byte(raw), &input); err != nil || input.WorkflowVersionID <= 0 {
return 0, errorsx.InvalidParam("invalid playbook call")
}
return input.WorkflowVersionID, nil
}
func writeHybridResult(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, playbook *Summary, responsePolicy autonomousResponsePolicy, toolCalls []svc.EngineToolCallInput) (*Summary, error) {
runID, err := writeHybridAudit(req, startedAt, result, inputPreview, historyCount, retrieverCount, skillContext, toolCalls, responsePolicy, false, nil)
if err != nil {
return nil, err
}
return &Summary{Status: "completed", ReplyText: strings.TrimSpace(result.Content), ModelName: result.ModelName, PromptTokens: result.PromptTokens, CompletionTokens: result.CompletionTokens, HistoryMessageCount: historyCount, RetrieverCount: retrieverCount, AgentRunID: runID, WorkflowRunID: workflowRunIDFromSummary(playbook)}, nil
}
func writeHybridAudit(req Request, startedAt time.Time, result *ai.ChatCompletionResult, inputPreview string, historyCount, retrieverCount int, skillContext autonomousSkillContext, toolCalls []svc.EngineToolCallInput, responsePolicy autonomousResponsePolicy, interrupted bool, cause error) (int64, error) {
endedAt := time.Now()
status, errorMessage, outputPreview := "completed", "", ""
promptTokens, completionTokens := 0, 0
if interrupted {
status = "interrupted"
} else if cause != nil {
status, errorMessage = "failed", cause.Error()
} else if result != nil {
outputPreview, promptTokens, completionTokens = strings.TrimSpace(result.Content), result.PromptTokens, result.CompletionTokens
}
steps := autonomousAdditionalSteps(req, retrieverCount, nil, skillContext, responsePolicy)
for _, call := range toolCalls {
if call.ToolCode == hybridPlaybookToolCode {
steps = append(steps, svc.EngineStepInput{StepType: "playbook", StepCode: hybridPlaybookToolCode, WorkflowRunID: workflowRunIDFromToolResult(call.ResultPreview), Status: call.Status, InputPreview: call.ArgumentsPreview, OutputPreview: call.ResultPreview, ErrorMessage: call.ErrorMessage})
}
}
var runID int64
err := sqls.WithTransaction(func(tx *sqls.TxContext) error {
var recordErr error
runID, recordErr = svc.AgentRunService.RecordEngineRun(tx.Tx, svc.EngineAgentRunInput{ConversationID: req.Conversation.ID, AIAgentID: req.AIAgent.ID, AgentRevisionID: req.AIAgent.PublishedRevisionID, SourceMessageID: req.UserMessage.ID, EngineCode: "hybrid", Status: status, PromptTokens: promptTokens, CompletionTokens: completionTokens, StartedAt: startedAt, EndedAt: &endedAt, ErrorMessage: errorMessage, TraceData: `{"engine":"hybrid"}`, StepType: "model", StepCode: "chat_completion", StepInputPreview: inputPreview, StepOutputPreview: outputPreview, AdditionalSteps: steps, ToolCalls: toolCalls})
return recordErr
})
return runID, err
}
func workflowRunIDFromSummary(summary *Summary) int64 {
if summary == nil {
return 0
}
return summary.WorkflowRunID
}
func workflowRunIDFromToolResult(value string) int64 {
var id int64
_, _ = fmt.Sscanf(value, "workflowRunId=%d", &id)
return id
}
var _ Engine = (*HybridEngine)(nil)
+98 -80
View File
@@ -8,14 +8,15 @@ import (
workflowexecutor "agent-desk/internal/ai/runtime/workflow"
"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 {
+48 -3
View File
@@ -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)
+4 -2
View File
@@ -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) {
+44
View File
@@ -0,0 +1,44 @@
package runtime
import (
"context"
applicationruntime "agent-desk/internal/ai/application/runtime"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/dto/response"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
svc "agent-desk/internal/services"
)
func init() {
svc.AgentEvaluationRunHook = RunAgentEvaluation
}
func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
agent := svc.AIAgentService.Get(req.AIAgentID)
if agent == nil || agent.Status != enums.StatusOk {
return nil, errorsx.InvalidParamI18n("error.e0007")
}
config := svc.AIConfigService.Get(agent.AIConfigID)
if config == nil {
return nil, errorsx.InvalidParamI18n("error.e0008")
}
cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases))
for _, item := range req.Cases {
cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect})
}
report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, req.EngineCode, *agent, *config, cases)
if err != nil {
return nil, err
}
csv, err := report.CSV()
if err != nil {
return nil, err
}
ret := &response.AgentEvaluationReportResponse{EngineCode: report.EngineCode, Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))}
for _, item := range report.Results {
ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, EngineCode: item.EngineCode, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding})
}
return ret, nil
}
@@ -0,0 +1,79 @@
// Package readtools executes deterministic, read-only graph tools through the
// shared Tool Registry boundary.
package readtools
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"agent-desk/internal/ai/runtime/graphs"
"agent-desk/internal/ai/runtime/retrievers"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/toolx"
)
func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool")
}
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
if err != nil {
return aitooling.Definition{}, "", err
}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: definition,
Arguments: arguments,
Policy: policy,
}); err != nil {
return definition, "", err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
data, err := json.Marshal(arguments)
if err != nil {
return definition, "", err
}
switch toolCode {
case toolx.GraphTriageServiceRequest.Code:
result, err := graphs.NewTriageServiceRequestGraph(conversation).Run(ctx, string(data))
return definition, result, err
case toolx.GraphAnalyzeConversation.Code:
result, err := graphs.NewAnalyzeConversationGraph(conversation).Run(ctx, string(data))
return definition, result, err
default:
result, err := graphs.NewPrepareTicketDraftGraph(conversation).Run(ctx, string(data))
return definition, result, err
}
}
// RetrieveKnowledge executes the built-in knowledge tool after the same
// registry policy and timeout checks used by graph tools.
func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseIDs []int64, query string, policy aitooling.Policy) (aitooling.Definition, *retrievers.KnowledgeRetrieveResult, error) {
definition, err := aitooling.DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code)
if err != nil {
return aitooling.Definition{}, nil, err
}
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs}
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
Definition: definition,
Arguments: arguments,
Policy: policy,
}); err != nil {
return definition, nil, err
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := retrievers.NewKnowledgeRetriever(agent, knowledgeBaseIDs).RetrieveContext(ctx, strings.TrimSpace(query))
return definition, result, err
}
@@ -0,0 +1,26 @@
package readtools
import (
"context"
"testing"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/toolx"
)
func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) {
definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{
"observedIssue": "需要分析的问题",
}, aitooling.Policy{
AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code},
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
Confirmed: true,
})
if err == nil {
t.Fatal("expected policy guard to reject the graph tool")
}
if definition.Code != toolx.GraphAnalyzeConversation.Code {
t.Fatalf("definition code = %q, want %q", definition.Code, toolx.GraphAnalyzeConversation.Code)
}
}
+5 -3
View File
@@ -3,6 +3,8 @@ package runtime
import (
"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())
+34
View File
@@ -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
+2 -1
View File
@@ -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
}
+17
View File
@@ -49,6 +49,23 @@ func TestReplyEligibilityCanReply(t *testing.T) {
}
}
func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) {
conversation := models.Conversation{ID: 101, ChannelID: 7}
agent := models.AIAgent{ID: 9, RolloutPercent: 50}
first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100})
for range 20 {
if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first {
t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got)
}
}
if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 {
t.Fatal("unexpected rollout percent normalization")
}
if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) {
t.Fatal("legacy zero rollout values must preserve full rollout")
}
}
func TestResolveReplyTimeout(t *testing.T) {
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,
+10 -19
View File
@@ -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) {
+85 -45
View File
@@ -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{},
+91
View File
@@ -0,0 +1,91 @@
package ai
import (
"context"
"fmt"
"strings"
openai "github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/shared"
"agent-desk/internal/models"
)
type ToolDefinition struct {
Name string
Description string
Parameters map[string]any
}
type ToolCall struct {
ID string
Name string
Arguments string
}
type ToolCallExecutor func(context.Context, ToolCall) (string, error)
type ToolLoopResult struct {
ChatCompletionResult
ToolCalls []ToolCall
}
// ChatWithTools executes a bounded OpenAI-compatible function-calling loop.
// Tool execution stays in the caller so business operations remain behind the
// application Tool Registry and Service layer.
func (s *llm) ChatWithTools(ctx context.Context, config models.AIConfig, systemPrompt, userPrompt string, definitions []ToolDefinition, maxSteps int, execute ToolCallExecutor) (*ToolLoopResult, error) {
if len(definitions) == 0 || execute == nil {
result, err := s.ChatWithConfig(ctx, config, systemPrompt, userPrompt)
if err != nil {
return nil, err
}
return &ToolLoopResult{ChatCompletionResult: *result}, nil
}
if maxSteps <= 0 {
maxSteps = 5
}
messages := []openai.ChatCompletionMessageParamUnion{}
if strings.TrimSpace(systemPrompt) != "" {
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfSystem: &openai.ChatCompletionSystemMessageParam{Content: openai.ChatCompletionSystemMessageParamContentUnion{OfString: openai.String(systemPrompt)}}})
}
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfUser: &openai.ChatCompletionUserMessageParam{Content: openai.ChatCompletionUserMessageParamContentUnion{OfString: openai.String(userPrompt)}}})
tools := make([]openai.ChatCompletionToolUnionParam, 0, len(definitions))
for _, definition := range definitions {
tools = append(tools, openai.ChatCompletionToolUnionParam{OfFunction: &openai.ChatCompletionFunctionToolParam{Function: shared.FunctionDefinitionParam{Name: definition.Name, Description: openai.String(definition.Description), Parameters: shared.FunctionParameters(definition.Parameters)}}})
}
client := newOpenAIClient(config)
allCalls := make([]ToolCall, 0)
for step := 0; step < maxSteps; step++ {
params := openai.ChatCompletionNewParams{Messages: messages, Model: shared.ChatModel(config.ModelName), Tools: tools}
if config.MaxOutputTokens > 0 {
params.MaxCompletionTokens = openai.Int(int64(config.MaxOutputTokens))
}
applyProviderSpecificChatParams(&params, config)
response, err := client.Chat.Completions.New(ctx, params)
if err != nil {
return nil, fmt.Errorf("tool loop chat completion failed: %w", err)
}
if len(response.Choices) == 0 {
return nil, fmt.Errorf("tool loop returned no choices")
}
message := response.Choices[0].Message
if len(message.ToolCalls) == 0 {
return &ToolLoopResult{ChatCompletionResult: ChatCompletionResult{Content: strings.TrimSpace(message.Content), ModelName: config.ModelName, PromptTokens: int(response.Usage.PromptTokens), CompletionTokens: int(response.Usage.CompletionTokens)}, ToolCalls: allCalls}, nil
}
messages = append(messages, message.ToParam())
for _, rawCall := range message.ToolCalls {
call := rawCall.AsFunction()
if call.ID == "" || call.Function.Name == "" {
return nil, fmt.Errorf("tool loop received unsupported tool call")
}
toolCall := ToolCall{ID: call.ID, Name: call.Function.Name, Arguments: call.Function.Arguments}
allCalls = append(allCalls, toolCall)
output, callErr := execute(ctx, toolCall)
if callErr != nil {
output = "tool execution failed: " + callErr.Error()
}
messages = append(messages, openai.ChatCompletionMessageParamUnion{OfTool: &openai.ChatCompletionToolMessageParam{ToolCallID: call.ID, Content: openai.ChatCompletionToolMessageParamContentUnion{OfString: openai.String(output)}}})
}
}
return nil, fmt.Errorf("tool loop exceeded maximum steps")
}
+82
View File
@@ -0,0 +1,82 @@
package ai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
)
func TestChatWithToolsExecutesToolAndContinuesConversation(t *testing.T) {
var requestCount atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("unexpected path: %s", r.URL.Path)
}
var body struct {
Messages []struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
} `json:"tool_calls"`
} `json:"messages"`
Tools []json.RawMessage `json:"tools"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
switch requestCount.Add(1) {
case 1:
if len(body.Tools) != 1 || len(body.Messages) != 2 || body.Messages[1].Role != "user" {
t.Fatalf("unexpected first request: %+v", body)
}
_, _ = w.Write([]byte(`{"id":"chatcmpl-1","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"","tool_calls":[{"id":"call-1","type":"function","function":{"name":"lookup","arguments":"{\"q\":\"refund\"}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`))
case 2:
if len(body.Messages) != 4 || body.Messages[2].Role != "assistant" || len(body.Messages[2].ToolCalls) != 1 || body.Messages[3].Role != "tool" || body.Messages[3].Content != "refund policy" {
t.Fatalf("tool result was not continued in second request: %+v", body.Messages)
}
_, _ = w.Write([]byte(`{"id":"chatcmpl-2","object":"chat.completion","created":2,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"Refunds are available within 30 days."},"finish_reason":"stop"}],"usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}}`))
default:
t.Fatalf("unexpected extra request")
}
}))
defer server.Close()
var executed ToolCall
result, err := LLM.ChatWithTools(context.Background(), models.AIConfig{
Provider: enums.AIProviderOpenAI,
BaseURL: server.URL + "/v1",
APIKey: "test-key",
ModelName: "test-model",
}, "You are helpful.", "What is the refund policy?", []ToolDefinition{{
Name: "lookup",
Description: "Look up a policy.",
Parameters: map[string]any{"type": "object"},
}}, 3, func(_ context.Context, call ToolCall) (string, error) {
executed = call
return "refund policy", nil
})
if err != nil {
t.Fatalf("ChatWithTools: %v", err)
}
if got, want := result.Content, "Refunds are available within 30 days."; got != want {
t.Fatalf("result content = %q, want %q", got, want)
}
if executed.Name != "lookup" || executed.ID != "call-1" || executed.Arguments != `{"q":"refund"}` {
t.Fatalf("executed tool call = %+v", executed)
}
if len(result.ToolCalls) != 1 || result.PromptTokens != 20 || result.CompletionTokens != 4 {
t.Fatalf("unexpected result: %+v", result)
}
if got := requestCount.Load(); got != 2 {
t.Fatalf("request count = %d, want 2", got)
}
}
+67
View File
@@ -0,0 +1,67 @@
package tooling
import (
"context"
"fmt"
"strings"
"time"
"agent-desk/internal/ai/mcps"
"agent-desk/internal/pkg/toolx"
)
// MCPExecutor is the single execution boundary for dynamically discovered
// MCP tools. Engine adapters supply the policy for the current Agent run.
type MCPExecutor struct {
registry *Registry
runtime *mcps.RuntimeService
}
var DefaultMCPExecutor = NewMCPExecutor(DefaultRegistry, mcps.Runtime)
func NewMCPExecutor(registry *Registry, runtime *mcps.RuntimeService) *MCPExecutor {
return &MCPExecutor{registry: registry, runtime: runtime}
}
func (e *MCPExecutor) Execute(ctx context.Context, toolCode string, arguments map[string]any, policy Policy) (Definition, *mcps.ToolCallResult, error) {
definition, err := e.registry.Resolve(toolCode)
if err != nil {
return Definition{}, nil, err
}
if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Arguments: arguments, Policy: policy}); err != nil {
return Definition{}, nil, err
}
serverCode, toolName := toolx.SplitMCPToolCode(strings.TrimSpace(definition.Code))
if serverCode == "" || toolName == "" {
return Definition{}, nil, &UnsupportedExecutionError{ToolCode: definition.Code}
}
if e.runtime == nil {
return Definition{}, nil, fmt.Errorf("MCP executor runtime is not configured")
}
if definition.TimeoutMS > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
defer cancel()
}
result, err := e.runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments))
return definition, result, err
}
type UnsupportedExecutionError struct {
ToolCode string
}
func (e *UnsupportedExecutionError) Error() string {
return "tool is not executable through MCP: " + e.ToolCode
}
func cloneArguments(input map[string]any) map[string]any {
if len(input) == 0 {
return map[string]any{}
}
ret := make(map[string]any, len(input))
for key, value := range input {
ret[key] = value
}
return ret
}
+219
View File
@@ -0,0 +1,219 @@
// Package tooling provides the engine-independent tool governance boundary.
package tooling
import (
"encoding/json"
"fmt"
"strings"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/toolx"
)
const (
RiskLevelRead = "read"
RiskLevelWrite = "write"
RiskLevelSensitive = "sensitive"
)
// Definition is the normalized, engine-independent description of a tool.
type Definition struct {
Code string
Name string
Description string
InputSchema map[string]any
SourceType enums.ToolSourceType
RiskLevel string
RequireConfirmation bool
MaxCallsPerRun int
TimeoutMS int
IdempotencyMode string
}
// Policy is supplied by the caller's agent/runtime context for one invocation.
// An empty AllowedToolCodes means the caller did not impose an allow-list.
type Policy struct {
AllowedToolCodes []string
SkillAllowedToolCodes []string
AllowedRiskLevels []string
CallCount int
TotalCallCount int
MaxTotalCalls int
MaxArgumentBytes int
Confirmed bool
}
type Invocation struct {
Definition Definition
Arguments map[string]any
Policy Policy
}
// PolicyGuard is the reusable enforcement point for every engine/tool adapter.
type PolicyGuard struct{}
var DefaultPolicyGuard = &PolicyGuard{}
type Registry struct{}
var DefaultRegistry = NewRegistry()
func NewRegistry() *Registry {
return &Registry{}
}
func (r *Registry) Resolve(toolCode string) (Definition, error) {
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
if toolCode == "" {
return Definition{}, fmt.Errorf("tool code is required")
}
if spec, ok := toolx.GetRegisteredToolSpec(toolCode); ok {
return definitionFromSpec(spec), nil
}
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)
if serverCode == "" || toolName == "" {
return Definition{}, fmt.Errorf("unsupported tool code: %s", toolCode)
}
// MCP metadata cannot reliably describe side effects. Treat it as sensitive
// until an administrator provides a more specific policy in a later phase.
return Definition{
Code: toolCode,
Name: toolName,
InputSchema: map[string]any{"type": "object", "additionalProperties": true},
SourceType: enums.ToolSourceTypeMCP,
RiskLevel: RiskLevelSensitive,
RequireConfirmation: true,
MaxCallsPerRun: 3,
TimeoutMS: 30000,
IdempotencyMode: "caller",
}, nil
}
func (r *Registry) Authorize(definition Definition, policy Policy) error {
return DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: policy})
}
func (g *PolicyGuard) Authorize(invocation Invocation) error {
definition := invocation.Definition
policy := invocation.Policy
if definition.Code == "" {
return fmt.Errorf("tool definition is required")
}
if len(policy.AllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.AllowedToolCodes, definition.Code) {
return fmt.Errorf("tool is not allowed: %s", definition.Code)
}
if len(policy.SkillAllowedToolCodes) > 0 && !containsCanonicalToolCode(policy.SkillAllowedToolCodes, definition.Code) {
return fmt.Errorf("tool is not allowed by the selected skill: %s", definition.Code)
}
if len(policy.AllowedRiskLevels) > 0 && !containsString(policy.AllowedRiskLevels, definition.RiskLevel) {
return fmt.Errorf("tool risk level is not allowed: %s", definition.RiskLevel)
}
if definition.MaxCallsPerRun > 0 && policy.CallCount >= definition.MaxCallsPerRun {
return fmt.Errorf("tool call limit reached: %s", definition.Code)
}
if policy.MaxTotalCalls > 0 && policy.TotalCallCount >= policy.MaxTotalCalls {
return fmt.Errorf("total tool call limit reached")
}
if policy.MaxArgumentBytes > 0 {
encoded, err := json.Marshal(invocation.Arguments)
if err != nil {
return fmt.Errorf("tool arguments are not serializable: %w", err)
}
if len(encoded) > policy.MaxArgumentBytes {
return fmt.Errorf("tool arguments exceed size limit: %s", definition.Code)
}
}
if definition.RequireConfirmation && !policy.Confirmed {
return fmt.Errorf("tool confirmation is required: %s", definition.Code)
}
return nil
}
func definitionFromSpec(spec toolx.ToolSpec) Definition {
definition := Definition{
Code: spec.Code,
Name: spec.Name,
Description: spec.Description,
SourceType: spec.SourceType,
RiskLevel: RiskLevelRead,
MaxCallsPerRun: 8,
TimeoutMS: 15000,
IdempotencyMode: "none",
}
switch spec.Code {
case toolx.BuiltinConversationContext.Code:
definition.InputSchema = objectSchema(map[string]any{})
case toolx.BuiltinKnowledgeRetrieve.Code:
definition.InputSchema = requiredObjectSchema([]string{"query"}, map[string]any{"query": map[string]any{"type": "string"}})
case toolx.GraphTriageServiceRequest.Code:
definition.InputSchema = objectSchema(map[string]any{
"goal": map[string]any{"type": "string"},
"observedIssue": map[string]any{"type": "string"},
"needTicket": map[string]any{"type": "boolean"},
"needHumanHandoff": map[string]any{"type": "boolean"},
"additionalContext": map[string]any{"type": "string"},
})
case toolx.GraphAnalyzeConversation.Code:
definition.InputSchema = objectSchema(map[string]any{
"goal": map[string]any{"type": "string"},
"observedIssue": map[string]any{"type": "string"},
"needTicket": map[string]any{"type": "boolean"},
"needHumanHandoff": map[string]any{"type": "boolean"},
"needQualityCheck": map[string]any{"type": "boolean"},
"additionalContext": map[string]any{"type": "string"},
})
case toolx.GraphPrepareTicketDraft.Code:
definition.InputSchema = objectSchema(map[string]any{
"title": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"issue": map[string]any{"type": "string"},
"impact": map[string]any{"type": "string"},
"expectedOutcome": map[string]any{"type": "string"},
"currentAttempt": map[string]any{"type": "string"},
})
case toolx.GraphCreateTicketConfirm.Code:
definition.RiskLevel = RiskLevelWrite
definition.RequireConfirmation = true
definition.MaxCallsPerRun = 1
definition.IdempotencyMode = "business"
definition.InputSchema = requiredObjectSchema([]string{"title", "description"}, map[string]any{
"title": map[string]any{"type": "string"}, "description": map[string]any{"type": "string"},
})
case toolx.GraphHandoffConversation.Code:
definition.RiskLevel = RiskLevelWrite
definition.RequireConfirmation = true
definition.MaxCallsPerRun = 1
definition.IdempotencyMode = "business"
definition.InputSchema = objectSchema(map[string]any{"reason": map[string]any{"type": "string"}})
}
return definition
}
func objectSchema(properties map[string]any) map[string]any {
return map[string]any{"type": "object", "properties": properties}
}
func requiredObjectSchema(required []string, properties map[string]any) map[string]any {
schema := objectSchema(properties)
schema["required"] = required
return schema
}
func containsString(items []string, target string) bool {
for _, item := range items {
if strings.EqualFold(strings.TrimSpace(item), strings.TrimSpace(target)) {
return true
}
}
return false
}
func containsCanonicalToolCode(items []string, target string) bool {
target = toolx.NormalizeToolCodeAlias(strings.TrimSpace(target))
for _, item := range items {
if toolx.NormalizeToolCodeAlias(strings.TrimSpace(item)) == target {
return true
}
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package tooling
import (
"strings"
"testing"
"agent-desk/internal/pkg/toolx"
)
func TestRegistryResolvesRegisteredToolPolicy(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.MaxCallsPerRun != 1 {
t.Fatalf("unexpected definition: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{toolx.GraphCreateTicketConfirm.Code}}); err == nil {
t.Fatal("expected confirmation requirement")
}
}
func TestRegistryIncludesGraphInputSchemaAndRiskPolicy(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphCreateTicketConfirm.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.InputSchema["type"] != "object" || len(definition.InputSchema["required"].([]string)) != 2 {
t.Fatalf("unexpected graph schema: %#v", definition.InputSchema)
}
if err := DefaultPolicyGuard.Authorize(Invocation{Definition: definition, Policy: Policy{
AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelRead}, Confirmed: true,
}}); err == nil || !strings.Contains(err.Error(), "risk level") {
t.Fatalf("expected risk policy rejection, got %v", err)
}
}
func TestRegistryRequiresConfirmationForHandoff(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphHandoffConversation.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelWrite || !definition.RequireConfirmation || definition.IdempotencyMode != "business" {
t.Fatalf("unexpected handoff policy: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{RiskLevelWrite}}); err == nil || !strings.Contains(err.Error(), "confirmation") {
t.Fatalf("expected handoff confirmation rejection, got %v", err)
}
}
func TestRegistryIncludesAllTicketDraftToolInputs(t *testing.T) {
definition, err := DefaultRegistry.Resolve(toolx.GraphPrepareTicketDraft.Code)
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
properties, _ := definition.InputSchema["properties"].(map[string]any)
for _, key := range []string{"title", "description", "issue", "impact", "expectedOutcome", "currentAttempt"} {
if _, ok := properties[key]; !ok {
t.Fatalf("ticket draft schema missing %q: %#v", key, definition.InputSchema)
}
}
}
func TestRegistryTreatsMCPToolsAsSensitive(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if definition.RiskLevel != RiskLevelSensitive || !definition.RequireConfirmation {
t.Fatalf("unexpected MCP definition: %#v", definition)
}
if err := DefaultRegistry.Authorize(definition, Policy{AllowedToolCodes: []string{"knowledge/search"}, Confirmed: true}); err != nil {
t.Fatalf("Authorize returned error: %v", err)
}
}
func TestSanitizePreviewMasksAndBoundsSecrets(t *testing.T) {
preview := SanitizePreview(`authorization=Bearer-secret {"token":"abc123"}`)
if strings.Contains(preview, "Bearer-secret") || strings.Contains(preview, "abc123") {
t.Fatalf("secret leaked in preview: %q", preview)
}
}
func TestNormalizeCustomerReplyRejectsSecretAndNormalizesText(t *testing.T) {
if _, err := NormalizeCustomerReply("token=abc123"); err == nil {
t.Fatal("expected sensitive reply to be rejected")
}
reply, err := NormalizeCustomerReply(" first\x00\n\n\n\nsecond ")
if err != nil || reply != "first\n\nsecond" {
t.Fatalf("unexpected normalized reply: %q err=%v", reply, err)
}
}
func TestMCPExecutorRejectsUnconfirmedToolBeforeRuntimeCall(t *testing.T) {
executor := NewMCPExecutor(DefaultRegistry, nil)
_, _, err := executor.Execute(t.Context(), "knowledge/search", nil, Policy{
AllowedToolCodes: []string{"knowledge/search"},
})
if err == nil || !strings.Contains(err.Error(), "confirmation") {
t.Fatalf("expected confirmation rejection, got %v", err)
}
}
func TestPolicyGuardRejectsTotalCallsAndOversizedArguments(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
if err := DefaultPolicyGuard.Authorize(Invocation{
Definition: definition,
Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, TotalCallCount: 2, MaxTotalCalls: 2},
}); err == nil || !strings.Contains(err.Error(), "total") {
t.Fatalf("expected total call rejection, got %v", err)
}
if err := DefaultPolicyGuard.Authorize(Invocation{
Definition: definition, Arguments: map[string]any{"query": strings.Repeat("x", 40)},
Policy: Policy{AllowedToolCodes: []string{definition.Code}, Confirmed: true, MaxArgumentBytes: 16},
}); err == nil || !strings.Contains(err.Error(), "size") {
t.Fatalf("expected argument size rejection, got %v", err)
}
}
func TestPolicyGuardRejectsToolOutsideSelectedSkillWhitelist(t *testing.T) {
definition, err := DefaultRegistry.Resolve("knowledge/search")
if err != nil {
t.Fatalf("Resolve returned error: %v", err)
}
err = DefaultPolicyGuard.Authorize(Invocation{
Definition: definition,
Policy: Policy{
AllowedToolCodes: []string{"knowledge/search"},
SkillAllowedToolCodes: []string{"customer/profile"},
Confirmed: true,
},
})
if err == nil || !strings.Contains(err.Error(), "selected skill") {
t.Fatalf("expected skill whitelist rejection, got %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package tooling
import (
"fmt"
"strings"
"unicode"
)
const maxCustomerReplyRunes = 8000
// NormalizeCustomerReply applies the final plain-text boundary before an AI
// response enters a customer conversation. It rejects likely credential
// assignments instead of masking them, because a masked secret is not useful
// customer-facing content.
func NormalizeCustomerReply(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("ai reply is empty")
}
if secretAssignmentPattern.MatchString(value) {
return "", fmt.Errorf("ai reply contains sensitive credential data")
}
var builder strings.Builder
for _, r := range value {
if unicode.IsControl(r) && r != '\n' && r != '\t' {
continue
}
builder.WriteRune(r)
}
value = strings.TrimSpace(builder.String())
for strings.Contains(value, "\n\n\n") {
value = strings.ReplaceAll(value, "\n\n\n", "\n\n")
}
if value == "" {
return "", fmt.Errorf("ai reply is empty")
}
if len([]rune(value)) > maxCustomerReplyRunes {
return "", fmt.Errorf("ai reply exceeds maximum length")
}
return value, nil
}
+25
View File
@@ -0,0 +1,25 @@
package tooling
import (
"regexp"
"strings"
)
const maxPreviewChars = 4000
var secretAssignmentPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
// SanitizePreview keeps audit/model previews bounded and masks common secrets.
// It intentionally operates on plain text so it also covers malformed JSON.
func SanitizePreview(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value = secretAssignmentPattern.ReplaceAllString(value, "$1$2***")
runes := []rune(value)
if len(runes) <= maxPreviewChars {
return value
}
return strings.TrimSpace(string(runes[:maxPreviewChars])) + "\n[preview truncated]"
}
+7 -6
View File
@@ -248,14 +248,15 @@ func DefaultRegistry() *Registry {
},
},
NodeSpec{
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")}
+15
View File
@@ -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)
+1
View File
@@ -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"))
+9
View File
@@ -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
}
+145
View File
@@ -0,0 +1,145 @@
package builders
import (
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto/response"
)
func BuildAgentRun(item *models.AgentRun) response.AgentRunResponse {
if item == nil {
return response.AgentRunResponse{}
}
return response.AgentRunResponse{
ID: item.ID,
ConversationID: item.ConversationID,
AIAgentID: item.AIAgentID,
AgentRevisionID: item.AgentRevisionID,
SourceMessageID: item.SourceMessageID,
WorkflowRunID: item.WorkflowRunID,
EngineCode: item.EngineCode,
Status: item.Status,
PromptTokens: item.PromptTokens,
CompletionTokens: item.CompletionTokens,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: agentRunDurationMS(item.StartedAt, item.EndedAt),
ErrorMessage: item.ErrorMessage,
TraceData: item.TraceData,
CreatedAt: formatAgentRunTime(item.CreatedAt),
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunDetail(item *models.AgentRun, steps []models.AgentStep, toolCalls []models.AgentToolCall, feedback *models.AgentRunQualityFeedback) response.AgentRunResponse {
ret := BuildAgentRun(item)
ret.Steps = BuildAgentStepList(steps)
ret.ToolCalls = BuildAgentToolCallList(toolCalls)
ret.QualityFeedback = BuildAgentRunQualityFeedback(feedback)
return ret
}
func BuildAgentRunQualityFeedback(item *models.AgentRunQualityFeedback) *response.AgentRunQualityFeedbackResponse {
if item == nil {
return nil
}
return &response.AgentRunQualityFeedbackResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
ResolutionStatus: item.ResolutionStatus,
EvidenceStatus: item.EvidenceStatus,
Comment: item.Comment,
UpdateUserName: item.UpdateUserName,
UpdatedAt: formatAgentRunTime(item.UpdatedAt),
}
}
func BuildAgentRunList(list []models.AgentRun) []response.AgentRunResponse {
ret := make([]response.AgentRunResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentRun(&list[i]))
}
return ret
}
func BuildAgentStep(item *models.AgentStep) response.AgentStepResponse {
if item == nil {
return response.AgentStepResponse{}
}
return response.AgentStepResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
WorkflowRunID: item.WorkflowRunID,
StepType: item.StepType,
StepCode: item.StepCode,
Status: item.Status,
InputPreview: item.InputPreview,
OutputPreview: item.OutputPreview,
ErrorMessage: item.ErrorMessage,
StartedAt: formatAgentRunTime(item.StartedAt),
EndedAt: formatAgentRunTimePtr(item.EndedAt),
DurationMS: item.DurationMS,
}
}
func BuildAgentStepList(list []models.AgentStep) []response.AgentStepResponse {
ret := make([]response.AgentStepResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentStep(&list[i]))
}
return ret
}
func BuildAgentToolCall(item *models.AgentToolCall) response.AgentToolCallResponse {
if item == nil {
return response.AgentToolCallResponse{}
}
return response.AgentToolCallResponse{
ID: item.ID,
AgentRunID: item.AgentRunID,
AgentStepID: item.AgentStepID,
ToolCode: item.ToolCode,
RiskLevel: item.RiskLevel,
RequireConfirm: item.RequireConfirm,
Status: item.Status,
ArgumentsPreview: item.ArgumentsPreview,
ResultPreview: item.ResultPreview,
ErrorMessage: item.ErrorMessage,
DurationMS: item.DurationMS,
CreatedAt: formatAgentRunTime(item.CreatedAt),
}
}
func BuildAgentToolCallList(list []models.AgentToolCall) []response.AgentToolCallResponse {
ret := make([]response.AgentToolCallResponse, 0, len(list))
for i := range list {
ret = append(ret, BuildAgentToolCall(&list[i]))
}
return ret
}
func formatAgentRunTime(value time.Time) string {
if value.IsZero() {
return ""
}
return value.Format("2006-01-02 15:04:05")
}
func formatAgentRunTimePtr(value *time.Time) string {
if value == nil {
return ""
}
return formatAgentRunTime(*value)
}
func agentRunDurationMS(startedAt time.Time, endedAt *time.Time) int64 {
if startedAt.IsZero() || endedAt == nil || endedAt.IsZero() {
return 0
}
duration := endedAt.Sub(startedAt).Milliseconds()
if duration < 0 {
return 0
}
return duration
}
+9
View File
@@ -8,6 +8,7 @@ import (
workflowregistry "agent-desk/internal/ai/workflow/registry"
"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)
}
+115 -29
View File
@@ -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
View File
@@ -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"`
}
+20
View File
@@ -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"`
+11 -6
View File
@@ -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"`
}
+52 -30
View File
@@ -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"`
+21 -17
View File
@@ -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,
}
}
+28
View File
@@ -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 (
+36
View File
@@ -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"
+23 -2
View File
@@ -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")
}
}
+214
View File
@@ -0,0 +1,214 @@
package services
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRevisionService = newAgentRevisionService()
func newAgentRevisionService() *agentRevisionService {
return &agentRevisionService{}
}
type agentRevisionService struct{}
func (s *agentRevisionService) Get(id int64) *models.AgentRevision {
if id <= 0 {
return nil
}
return repositories.AgentRevisionRepository.Get(sqls.DB(), id)
}
func (s *agentRevisionService) FindByAgentID(agentID int64) []models.AgentRevision {
return repositories.AgentRevisionRepository.FindByAgentID(sqls.DB(), agentID)
}
type agentRevisionDefinition struct {
Agent agentRevisionAgent `json:"agent"`
Model agentRevisionModel `json:"model"`
WorkflowVersionID int64 `json:"workflowVersionId"`
WorkflowDefinition string `json:"workflowDefinition"`
}
// agentRevisionModel deliberately excludes APIKey. A revision must capture
// reproducible routing/model parameters without duplicating credentials.
type agentRevisionModel struct {
ConfigID int64 `json:"configId"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
ModelType string `json:"modelType"`
ModelName string `json:"modelName"`
MaxContextTokens int `json:"maxContextTokens"`
MaxOutputTokens int `json:"maxOutputTokens"`
TimeoutMS int `json:"timeoutMs"`
MaxRetryCount int `json:"maxRetryCount"`
}
type agentRevisionAgent struct {
Name string `json:"name"`
Description string `json:"description"`
AIConfigID int64 `json:"aiConfigId"`
RuntimeMode string `json:"runtimeMode"`
MaxSteps int `json:"maxSteps"`
ContextWindow int `json:"contextWindow"`
ToolPolicy string `json:"toolPolicy"`
KnowledgePolicy string `json:"knowledgePolicy"`
ServiceMode int `json:"serviceMode"`
SystemPrompt string `json:"systemPrompt"`
WelcomeMessage string `json:"welcomeMessage"`
ReplyTimeoutSeconds int `json:"replyTimeoutSeconds"`
TeamIDs string `json:"teamIds"`
HandoffMode int `json:"handoffMode"`
FallbackMode int `json:"fallbackMode"`
FallbackMessage string `json:"fallbackMessage"`
KnowledgeIDs string `json:"knowledgeIds"`
SkillIDs string `json:"skillIds"`
AllowedMCPTools string `json:"allowedMcpTools"`
}
// AgentRevisionSnapshot is the immutable runtime configuration restored from
// a published revision. Model credentials deliberately remain on the current
// AIConfig so credential rotation does not require republishing every Agent.
type AgentRevisionSnapshot struct {
Revision models.AgentRevision
Agent models.AIAgent
AIConfig models.AIConfig
}
// ResolvePublishedSnapshot restores a published Agent revision for runtime
// execution. Empty legacy definitions retain the current fields so historical
// records created before snapshot hydration remain executable.
func (s *agentRevisionService) ResolvePublishedSnapshot(agent models.AIAgent, config models.AIConfig) (*AgentRevisionSnapshot, error) {
if agent.PublishedRevisionID <= 0 {
return nil, errorsx.InvalidParam("autonomous agent is not published")
}
revision := repositories.AgentRevisionRepository.Get(sqls.DB(), agent.PublishedRevisionID)
if revision == nil || revision.AgentID != agent.ID || revision.Status != enums.StatusOk {
return nil, errorsx.InvalidParam("autonomous agent published revision does not exist")
}
snapshot := &AgentRevisionSnapshot{Revision: *revision, Agent: agent, AIConfig: config}
if strings.TrimSpace(revision.Definition) == "" {
return snapshot, nil
}
definition := agentRevisionDefinition{}
if err := json.Unmarshal([]byte(revision.Definition), &definition); err != nil {
return nil, errorsx.InvalidParam("autonomous agent published revision is invalid")
}
if definition.Agent.AIConfigID > 0 && definition.Agent.AIConfigID != config.ID {
return nil, errorsx.InvalidParam("published agent model config no longer matches")
}
applyRevisionAgentSnapshot(&snapshot.Agent, definition.Agent)
if definition.WorkflowVersionID > 0 {
snapshot.Agent.WorkflowVersionID = definition.WorkflowVersionID
}
applyRevisionModelSnapshot(&snapshot.AIConfig, definition.Model)
return snapshot, nil
}
func applyRevisionAgentSnapshot(agent *models.AIAgent, definition agentRevisionAgent) {
if agent == nil {
return
}
agent.Name = definition.Name
agent.Description = definition.Description
agent.AIConfigID = definition.AIConfigID
agent.RuntimeMode = enums.AIAgentRuntimeMode(definition.RuntimeMode)
agent.MaxSteps = definition.MaxSteps
agent.ContextWindow = definition.ContextWindow
agent.ToolPolicy = definition.ToolPolicy
agent.KnowledgePolicy = definition.KnowledgePolicy
agent.ServiceMode = enums.IMConversationServiceMode(definition.ServiceMode)
agent.SystemPrompt = definition.SystemPrompt
agent.WelcomeMessage = definition.WelcomeMessage
agent.ReplyTimeoutSeconds = definition.ReplyTimeoutSeconds
agent.TeamIDs = definition.TeamIDs
agent.HandoffMode = enums.AIAgentHandoffMode(definition.HandoffMode)
agent.FallbackMode = enums.AIAgentFallbackMode(definition.FallbackMode)
agent.FallbackMessage = definition.FallbackMessage
agent.KnowledgeIDs = definition.KnowledgeIDs
agent.SkillIDs = definition.SkillIDs
agent.AllowedMCPTools = definition.AllowedMCPTools
}
func applyRevisionModelSnapshot(config *models.AIConfig, definition agentRevisionModel) {
if config == nil || definition.ConfigID <= 0 {
return
}
config.Provider = enums.AIProvider(definition.Provider)
config.BaseURL = definition.BaseURL
config.ModelType = enums.AIModelType(definition.ModelType)
config.ModelName = definition.ModelName
config.MaxContextTokens = definition.MaxContextTokens
config.MaxOutputTokens = definition.MaxOutputTokens
config.TimeoutMS = definition.TimeoutMS
config.MaxRetryCount = definition.MaxRetryCount
}
// PublishWorkflowSnapshot keeps the Agent settings and its referenced
// workflow definition together as an immutable, reproducible revision.
func (s *agentRevisionService) PublishWorkflowSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
return s.publishSnapshot(db, agent, version, operator)
}
func (s *agentRevisionService) PublishSnapshot(db *gorm.DB, agent *models.AIAgent, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
return s.publishSnapshot(db, agent, nil, operator)
}
func (s *agentRevisionService) publishSnapshot(db *gorm.DB, agent *models.AIAgent, version *models.AIWorkflowVersion, operator *dto.AuthPrincipal) (*models.AgentRevision, error) {
model := agentRevisionModel{ConfigID: agent.AIConfigID}
if config := repositories.AIConfigRepository.Get(db, agent.AIConfigID); config != nil {
model = agentRevisionModel{
ConfigID: config.ID, Provider: string(config.Provider), BaseURL: config.BaseURL, ModelType: string(config.ModelType),
ModelName: config.ModelName, MaxContextTokens: config.MaxContextTokens, MaxOutputTokens: config.MaxOutputTokens,
TimeoutMS: config.TimeoutMS, MaxRetryCount: config.MaxRetryCount,
}
}
workflowVersionID := int64(0)
workflowDefinition := ""
if version != nil {
workflowVersionID = version.ID
workflowDefinition = version.Definition
}
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: agent.Name, Description: agent.Description, AIConfigID: agent.AIConfigID,
RuntimeMode: string(agent.RuntimeMode), MaxSteps: agent.MaxSteps, ContextWindow: agent.ContextWindow,
ToolPolicy: agent.ToolPolicy, KnowledgePolicy: agent.KnowledgePolicy, ServiceMode: int(agent.ServiceMode), SystemPrompt: agent.SystemPrompt,
WelcomeMessage: agent.WelcomeMessage, ReplyTimeoutSeconds: agent.ReplyTimeoutSeconds, TeamIDs: agent.TeamIDs, HandoffMode: int(agent.HandoffMode),
FallbackMode: int(agent.FallbackMode), FallbackMessage: agent.FallbackMessage, KnowledgeIDs: agent.KnowledgeIDs,
SkillIDs: agent.SkillIDs, AllowedMCPTools: agent.AllowedMCPTools,
},
Model: model,
WorkflowVersionID: workflowVersionID,
WorkflowDefinition: workflowDefinition,
}
data, err := json.Marshal(definition)
if err != nil {
return nil, err
}
now := time.Now()
hash := sha256.Sum256(data)
item := &models.AgentRevision{
AgentID: agent.ID, Revision: repositories.AgentRevisionRepository.MaxRevisionByAgentID(db, agent.ID) + 1,
WorkflowVersionID: workflowVersionID, Status: enums.StatusOk, Definition: string(data), DefinitionHash: hex.EncodeToString(hash[:]),
PublishedAt: &now, PublishedByID: operator.UserID, PublishedByName: operator.Username, AuditFields: utils.BuildAuditFields(operator),
}
if err := repositories.AgentRevisionRepository.Create(db, item); err != nil {
return nil, err
}
return item, nil
}
@@ -0,0 +1,50 @@
package services
import (
"encoding/json"
"strings"
"testing"
"agent-desk/internal/models"
"agent-desk/internal/pkg/enums"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
func TestAgentRevisionServiceRestoresPublishedSnapshotAndKeepsAPIKey(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentRevision{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
definition := agentRevisionDefinition{
Agent: agentRevisionAgent{
Name: "published agent", AIConfigID: 8, RuntimeMode: string(enums.AIAgentRuntimeModeAutonomous),
MaxSteps: 5, ContextWindow: 9, SystemPrompt: "published instruction", KnowledgeIDs: "4", ReplyTimeoutSeconds: 90,
},
Model: agentRevisionModel{ConfigID: 8, Provider: string(enums.AIProviderOpenAI), BaseURL: "https://published.example/v1", ModelType: string(enums.AIModelTypeLLM), ModelName: "published-model", TimeoutMS: 12000},
}
data, err := json.Marshal(definition)
if err != nil {
t.Fatalf("marshal definition: %v", err)
}
revision := &models.AgentRevision{AgentID: 7, Revision: 1, Status: enums.StatusOk, Definition: string(data)}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create revision: %v", err)
}
snapshot, err := AgentRevisionService.ResolvePublishedSnapshot(models.AIAgent{ID: 7, PublishedRevisionID: revision.ID, SystemPrompt: "draft instruction"}, models.AIConfig{ID: 8, APIKey: "rotated-secret", ModelName: "draft-model"})
if err != nil {
t.Fatalf("ResolvePublishedSnapshot: %v", err)
}
if snapshot.Agent.SystemPrompt != "published instruction" || snapshot.Agent.MaxSteps != 5 || snapshot.Agent.ReplyTimeoutSeconds != 90 {
t.Fatalf("agent snapshot not restored: %#v", snapshot.Agent)
}
if snapshot.AIConfig.ModelName != "published-model" || snapshot.AIConfig.BaseURL != "https://published.example/v1" || snapshot.AIConfig.APIKey != "rotated-secret" {
t.Fatalf("model snapshot not restored safely: %#v", snapshot.AIConfig)
}
}
+514
View File
@@ -0,0 +1,514 @@
package services
import (
"regexp"
"slices"
"sort"
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/pkg/utils"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
)
var AgentRunService = newAgentRunService()
func newAgentRunService() *agentRunService {
return &agentRunService{}
}
type agentRunService struct{}
type AgentRunMetrics struct {
TotalRuns int `json:"totalRuns"`
CompletedRuns int `json:"completedRuns"`
FailedRuns int `json:"failedRuns"`
InterruptedRuns int `json:"interruptedRuns"`
CompletionRate float64 `json:"completionRate"`
ToolCalls int `json:"toolCalls"`
ToolSuccessRate float64 `json:"toolSuccessRate"`
AverageSteps float64 `json:"averageSteps"`
AverageDurationMS int64 `json:"averageDurationMs"`
P95DurationMS int64 `json:"p95DurationMs"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
HandoffRate float64 `json:"handoffRate"`
KnowledgeFallbackRate float64 `json:"knowledgeFallbackRate"`
ResumedInterrupts int `json:"resumedInterrupts"`
ResolvedInterrupts int `json:"resolvedInterrupts"`
InterruptRecoveryRate float64 `json:"interruptRecoveryRate"`
ReviewedRuns int `json:"reviewedRuns"`
ResolvedRuns int `json:"resolvedRuns"`
ResolutionRate float64 `json:"resolutionRate"`
UnsupportedEvidenceRuns int `json:"unsupportedEvidenceRuns"`
UnsupportedEvidenceRate float64 `json:"unsupportedEvidenceRate"`
}
type AgentRunEngineComparison struct {
EngineCode string `json:"engineCode"`
Metrics AgentRunMetrics `json:"metrics"`
}
const maxAgentAuditPreviewChars = 4000
var agentAuditSecretPattern = regexp.MustCompile(`(?i)(?:"|')?(api[_-]?key|authorization|password|secret|token|cookie)(?:"|')?\s*([:=])\s*(?:"[^"]*"|'[^']*'|[^\s,;}]+)`)
func (s *agentRunService) Get(id int64) *models.AgentRun {
if id <= 0 {
return nil
}
return repositories.AgentRunRepository.Get(sqls.DB(), id)
}
func (s *agentRunService) FindPageByParams(queryParams *params.QueryParams) (list []models.AgentRun, paging *sqls.Paging) {
return repositories.AgentRunRepository.FindPageByParams(sqls.DB(), queryParams)
}
func (s *agentRunService) GetDetail(id int64) (*models.AgentRun, []models.AgentStep, []models.AgentToolCall) {
run := s.Get(id)
if run == nil {
return nil, nil, nil
}
return run,
repositories.AgentStepRepository.FindByAgentRunID(sqls.DB(), id),
repositories.AgentToolCallRepository.FindByAgentRunID(sqls.DB(), id)
}
func (s *agentRunService) GetLatestStepID(agentRunID int64) int64 {
step := repositories.AgentStepRepository.LastByAgentRunID(sqls.DB(), agentRunID)
if step == nil {
return 0
}
return step.ID
}
func (s *agentRunService) GetQualityFeedback(agentRunID int64) *models.AgentRunQualityFeedback {
return repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(sqls.DB(), agentRunID)
}
func (s *agentRunService) SaveQualityFeedback(req request.SaveAgentRunQualityFeedbackRequest, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.UnauthorizedI18n("error.auth.expired")
}
if req.AgentRunID <= 0 {
return errorsx.InvalidParam("agent run id is required")
}
if !slices.Contains(enums.AgentRunResolutionStatusValues, req.ResolutionStatus) || !slices.Contains(enums.AgentRunEvidenceStatusValues, req.EvidenceStatus) {
return errorsx.InvalidParam("invalid agent run quality feedback status")
}
comment := strings.TrimSpace(req.Comment)
if len([]rune(comment)) > 2000 {
return errorsx.InvalidParam("agent run quality feedback comment is too long")
}
return sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if repositories.AgentRunRepository.Get(ctx.Tx, req.AgentRunID) == nil {
return errorsx.InvalidParam("agent run does not exist")
}
current := repositories.AgentRunQualityFeedbackRepository.GetByAgentRunID(ctx.Tx, req.AgentRunID)
if current == nil {
return repositories.AgentRunQualityFeedbackRepository.Create(ctx.Tx, &models.AgentRunQualityFeedback{
AgentRunID: req.AgentRunID, ResolutionStatus: req.ResolutionStatus, EvidenceStatus: req.EvidenceStatus, Comment: comment,
AuditFields: utils.BuildAuditFields(operator),
})
}
return repositories.AgentRunQualityFeedbackRepository.Updates(ctx.Tx, current.ID, map[string]any{
"resolution_status": req.ResolutionStatus,
"evidence_status": req.EvidenceStatus,
"comment": comment,
"update_user_id": operator.UserID,
"update_user_name": operator.Username,
"updated_at": time.Now(),
})
})
}
// GetMetrics aggregates normalized audit records in Go so SQLite and MySQL
// use identical percentile and rate semantics.
func (s *agentRunService) GetMetrics(aiAgentID int64) AgentRunMetrics {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
metrics := s.aggregateMetrics(sqls.DB(), runs)
if len(runs) == 0 {
return metrics
}
conversationCount := repositories.ConversationRepository.CountByAIAgentID(sqls.DB(), aiAgentID)
if conversationCount > 0 {
metrics.HandoffRate = float64(repositories.ConversationRepository.CountHandoffByAIAgentID(sqls.DB(), aiAgentID)) / float64(conversationCount)
}
return metrics
}
// GetEngineComparisons keeps Workflow, Autonomous, and Hybrid reports based on
// the same normalized audit and reviewed-quality records. Conversation-level
// handoff is deliberately excluded because it cannot be attributed to one
// Engine after a mode change.
func (s *agentRunService) GetEngineComparisons(aiAgentID int64) []AgentRunEngineComparison {
runs := repositories.AgentRunRepository.FindRecent(sqls.DB(), aiAgentID, 5000)
groups := make(map[string][]models.AgentRun)
for _, run := range runs {
engineCode := strings.TrimSpace(run.EngineCode)
if engineCode == "" {
engineCode = "unknown"
}
groups[engineCode] = append(groups[engineCode], run)
}
engineCodes := make([]string, 0, len(groups))
for engineCode := range groups {
engineCodes = append(engineCodes, engineCode)
}
sort.Strings(engineCodes)
ret := make([]AgentRunEngineComparison, 0, len(engineCodes))
for _, engineCode := range engineCodes {
ret = append(ret, AgentRunEngineComparison{EngineCode: engineCode, Metrics: s.aggregateMetrics(sqls.DB(), groups[engineCode])})
}
return ret
}
func (s *agentRunService) aggregateMetrics(db *gorm.DB, runs []models.AgentRun) AgentRunMetrics {
metrics := AgentRunMetrics{TotalRuns: len(runs)}
if len(runs) == 0 {
return metrics
}
runIDs := make([]int64, 0, len(runs))
durations := make([]int64, 0, len(runs))
var durationTotal int64
for _, run := range runs {
runIDs = append(runIDs, run.ID)
switch run.Status {
case "completed":
metrics.CompletedRuns++
case "failed":
metrics.FailedRuns++
case "interrupted":
metrics.InterruptedRuns++
}
metrics.PromptTokens += int64(run.PromptTokens)
metrics.CompletionTokens += int64(run.CompletionTokens)
if run.EndedAt != nil {
duration := run.EndedAt.Sub(run.StartedAt).Milliseconds()
if duration < 0 {
duration = 0
}
durations = append(durations, duration)
durationTotal += duration
}
}
metrics.CompletionRate = float64(metrics.CompletedRuns) / float64(metrics.TotalRuns)
if len(durations) > 0 {
metrics.AverageDurationMS = durationTotal / int64(len(durations))
sort.Slice(durations, func(i, j int) bool { return durations[i] < durations[j] })
index := (len(durations)*95+99)/100 - 1
metrics.P95DurationMS = durations[index]
}
steps := repositories.AgentStepRepository.FindByAgentRunIDs(db, runIDs)
metrics.AverageSteps = float64(len(steps)) / float64(metrics.TotalRuns)
fallbackRunIDs := make(map[int64]struct{})
for _, step := range steps {
if step.StepType == "policy" && step.StepCode == "knowledge_evidence" {
fallbackRunIDs[step.AgentRunID] = struct{}{}
}
}
metrics.KnowledgeFallbackRate = float64(len(fallbackRunIDs)) / float64(metrics.TotalRuns)
toolCalls := repositories.AgentToolCallRepository.FindByAgentRunIDs(db, runIDs)
metrics.ToolCalls = len(toolCalls)
if len(toolCalls) > 0 {
completed := 0
for _, call := range toolCalls {
if call.Status == "completed" {
completed++
}
}
metrics.ToolSuccessRate = float64(completed) / float64(len(toolCalls))
}
interrupts := repositories.ConversationInterruptRepository.FindByAgentRunIDs(db, runIDs)
for _, interrupt := range interrupts {
if interrupt.ResumeCount <= 0 {
continue
}
metrics.ResumedInterrupts++
if interrupt.Status == "resolved" {
metrics.ResolvedInterrupts++
}
}
if metrics.ResumedInterrupts > 0 {
metrics.InterruptRecoveryRate = float64(metrics.ResolvedInterrupts) / float64(metrics.ResumedInterrupts)
}
feedbacks := repositories.AgentRunQualityFeedbackRepository.FindByAgentRunIDs(db, runIDs)
metrics.ReviewedRuns = len(feedbacks)
for _, feedback := range feedbacks {
if feedback.ResolutionStatus == enums.AgentRunResolutionStatusResolved {
metrics.ResolvedRuns++
}
if feedback.EvidenceStatus == enums.AgentRunEvidenceStatusUnsupported {
metrics.UnsupportedEvidenceRuns++
}
}
if metrics.ReviewedRuns > 0 {
metrics.ResolutionRate = float64(metrics.ResolvedRuns) / float64(metrics.ReviewedRuns)
metrics.UnsupportedEvidenceRate = float64(metrics.UnsupportedEvidenceRuns) / float64(metrics.ReviewedRuns)
}
return metrics
}
type WorkflowAgentRunInput struct {
WorkflowRunID int64
WorkflowVersionID int64
ConversationID int64
AIAgentID int64
SourceMessageID int64
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepInputPreview string
StepOutputPreview string
}
type EngineAgentRunInput struct {
ConversationID int64
AIAgentID int64
AgentRevisionID int64
SourceMessageID int64
EngineCode string
Status string
PromptTokens int
CompletionTokens int
StartedAt time.Time
EndedAt *time.Time
ErrorMessage string
TraceData string
StepType string
StepCode string
StepInputPreview string
StepOutputPreview string
AdditionalSteps []EngineStepInput
ToolCalls []EngineToolCallInput
}
type EngineStepInput struct {
StepType string
StepCode string
WorkflowRunID int64
Status string
InputPreview string
OutputPreview string
ErrorMessage string
}
type EngineToolCallInput struct {
ToolCode string
RiskLevel string
RequireConfirm bool
Status string
ArgumentsPreview string
ResultPreview string
ErrorMessage string
DurationMS int
}
// RecordHybridPlaybookResume closes or re-interrupts the Hybrid AgentRun that
// originally selected a Playbook. The detailed WorkflowRun remains separately
// auditable; this step preserves the parent AgentRun -> AgentStep -> WorkflowRun
// relationship across a human confirmation pause.
func (s *agentRunService) RecordHybridPlaybookResume(db *gorm.DB, agentRunID, workflowRunID int64, status, replyText string) error {
if agentRunID <= 0 {
return nil
}
run := repositories.AgentRunRepository.Get(db, agentRunID)
if run == nil || run.EngineCode != "hybrid" {
return nil
}
status = strings.TrimSpace(status)
if status == "" {
status = "completed"
}
now := time.Now()
durationMS := int(now.Sub(run.StartedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"status": status,
"ended_at": &now,
"error_message": "",
"updated_at": now,
}); err != nil {
return err
}
return repositories.AgentStepRepository.Create(db, &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: workflowRunID,
StepType: "playbook", StepCode: "playbook_resume", Status: status,
InputPreview: "human confirmation resume",
OutputPreview: sanitizeAgentAuditPreview(replyText),
StartedAt: now, EndedAt: &now, DurationMS: durationMS, CreatedAt: now,
})
}
// RecordEngineRun writes a non-workflow Engine audit run and its normalized
// root step in one transaction owned by the caller.
func (s *agentRunService) RecordEngineRun(db *gorm.DB, input EngineAgentRunInput) (int64, error) {
now := time.Now()
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
run := &models.AgentRun{
ConversationID: input.ConversationID, AIAgentID: input.AIAgentID, AgentRevisionID: input.AgentRevisionID,
SourceMessageID: input.SourceMessageID, EngineCode: strings.TrimSpace(input.EngineCode), Status: status,
PromptTokens: input.PromptTokens, CompletionTokens: input.CompletionTokens, StartedAt: startedAt, EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage), TraceData: sanitizeAgentAuditPreview(input.TraceData), CreatedAt: now, UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID, StepType: strings.TrimSpace(input.StepType), StepCode: strings.TrimSpace(input.StepCode), Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview), OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview), ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
for _, extra := range input.AdditionalSteps {
extraStep := &models.AgentStep{
AgentRunID: run.ID, WorkflowRunID: extra.WorkflowRunID, StepType: strings.TrimSpace(extra.StepType), StepCode: strings.TrimSpace(extra.StepCode),
Status: firstNonEmptyString(extra.Status, status), InputPreview: sanitizeAgentAuditPreview(extra.InputPreview), OutputPreview: sanitizeAgentAuditPreview(extra.OutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(extra.ErrorMessage), StartedAt: startedAt, EndedAt: input.EndedAt, DurationMS: durationMS, CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, extraStep); err != nil {
return 0, err
}
}
for _, call := range input.ToolCalls {
toolCall := &models.AgentToolCall{
AgentRunID: run.ID, AgentStepID: step.ID, ToolCode: strings.TrimSpace(call.ToolCode), RiskLevel: strings.TrimSpace(call.RiskLevel),
RequireConfirm: call.RequireConfirm, Status: firstNonEmptyString(call.Status, status), ArgumentsPreview: sanitizeAgentAuditPreview(call.ArgumentsPreview),
ResultPreview: sanitizeAgentAuditPreview(call.ResultPreview), ErrorMessage: sanitizeAgentAuditPreview(call.ErrorMessage), DurationMS: call.DurationMS, CreatedAt: now,
}
if err := repositories.AgentToolCallRepository.Create(db, toolCall); err != nil {
return 0, err
}
}
return run.ID, nil
}
func sanitizeAgentAuditPreview(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
value = agentAuditSecretPattern.ReplaceAllString(value, "$1$2***")
runes := []rune(value)
if len(runes) <= maxAgentAuditPreviewChars {
return value
}
return strings.TrimSpace(string(runes[:maxAgentAuditPreviewChars])) + "\n[preview truncated]"
}
func firstNonEmptyString(items ...string) string {
for _, item := range items {
if value := strings.TrimSpace(item); value != "" {
return value
}
}
return ""
}
// RecordWorkflowRun writes the Engine-independent audit record inside the
// caller's transaction. Workflow-specific tables remain the detailed source
// for node-level diagnosis while AgentRun becomes the cross-engine summary.
func (s *agentRunService) RecordWorkflowRun(db *gorm.DB, input WorkflowAgentRunInput) (int64, error) {
now := time.Now()
status := strings.TrimSpace(input.Status)
if status == "" {
status = "completed"
}
startedAt := input.StartedAt
if startedAt.IsZero() {
startedAt = now
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, input.WorkflowRunID)
agentRevisionID := int64(0)
if revision := repositories.AgentRevisionRepository.TakeByAgentIDAndWorkflowVersionID(db, input.AIAgentID, input.WorkflowVersionID); revision != nil {
agentRevisionID = revision.ID
}
if run == nil {
run = &models.AgentRun{
ConversationID: input.ConversationID,
AIAgentID: input.AIAgentID,
AgentRevisionID: agentRevisionID,
SourceMessageID: input.SourceMessageID,
WorkflowRunID: input.WorkflowRunID,
EngineCode: "workflow",
Status: status,
PromptTokens: input.PromptTokens,
CompletionTokens: input.CompletionTokens,
StartedAt: startedAt,
EndedAt: input.EndedAt,
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
TraceData: sanitizeAgentAuditPreview(input.TraceData),
CreatedAt: now,
UpdatedAt: now,
}
if err := repositories.AgentRunRepository.Create(db, run); err != nil {
return 0, err
}
} else if err := repositories.AgentRunRepository.Updates(db, run.ID, map[string]any{
"agent_revision_id": agentRevisionID,
"status": status,
"prompt_tokens": input.PromptTokens,
"completion_tokens": input.CompletionTokens,
"ended_at": input.EndedAt,
"error_message": sanitizeAgentAuditPreview(input.ErrorMessage),
"trace_data": sanitizeAgentAuditPreview(input.TraceData),
"updated_at": now,
}); err != nil {
return 0, err
}
durationMS := 0
if input.EndedAt != nil {
durationMS = int(input.EndedAt.Sub(startedAt).Milliseconds())
if durationMS < 0 {
durationMS = 0
}
}
step := &models.AgentStep{
AgentRunID: run.ID,
StepType: "workflow",
StepCode: "workflow",
Status: status,
InputPreview: sanitizeAgentAuditPreview(input.StepInputPreview),
OutputPreview: sanitizeAgentAuditPreview(input.StepOutputPreview),
ErrorMessage: sanitizeAgentAuditPreview(input.ErrorMessage),
StartedAt: startedAt,
EndedAt: input.EndedAt,
DurationMS: durationMS,
CreatedAt: now,
}
if err := repositories.AgentStepRepository.Create(db, step); err != nil {
return 0, err
}
return run.ID, nil
}
+237
View File
@@ -0,0 +1,237 @@
package services
import (
"strings"
"testing"
"time"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/enums"
"agent-desk/internal/pkg/httpx/params"
"agent-desk/internal/repositories"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentRunServiceFindsWorkflowAuditDetail(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
endedAt := now.Add(time.Second)
run := &models.AgentRun{
ConversationID: 11,
AIAgentID: 12,
WorkflowRunID: 13,
EngineCode: "workflow",
Status: "completed",
StartedAt: now,
EndedAt: &endedAt,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: run.ID, StepType: "workflow", Status: "completed", StartedAt: now, EndedAt: &endedAt, CreatedAt: now}).Error; err != nil {
t.Fatalf("create agent step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: run.ID, ToolCode: "knowledge.retrieve", Status: "completed", CreatedAt: now}).Error; err != nil {
t.Fatalf("create tool call: %v", err)
}
cnd := sqls.NewCnd().Eq("conversation_id", run.ConversationID).Desc("id").Page(1, 20)
queryParams := &params.QueryParams{Cnd: *cnd}
list, paging := AgentRunService.FindPageByParams(queryParams)
if len(list) != 1 || paging.Total != 1 || list[0].ID != run.ID {
t.Fatalf("unexpected agent run page: list=%#v paging=%#v", list, paging)
}
item, steps, toolCalls := AgentRunService.GetDetail(run.ID)
if item == nil || len(steps) != 1 || len(toolCalls) != 1 {
t.Fatalf("unexpected agent run detail: run=%#v steps=%#v toolCalls=%#v", item, steps, toolCalls)
}
}
func TestAgentRunServiceAssociatesWorkflowRevision(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
if err := db.Create(&models.AgentRevision{AgentID: 12, Revision: 1, WorkflowVersionID: 14}).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if _, err := AgentRunService.RecordWorkflowRun(db, WorkflowAgentRunInput{
WorkflowRunID: 13, WorkflowVersionID: 14, ConversationID: 11, AIAgentID: 12,
Status: "completed", StartedAt: now,
}); err != nil {
t.Fatalf("RecordWorkflowRun returned error: %v", err)
}
run := repositories.AgentRunRepository.TakeByWorkflowRunID(db, 13)
if run == nil || run.AgentRevisionID <= 0 {
t.Fatalf("expected AgentRun to link revision, got %#v", run)
}
if stepID := AgentRunService.GetLatestStepID(run.ID); stepID <= 0 {
t.Fatalf("expected normalized agent step id, got %d", stepID)
}
}
func TestAgentRunServiceRecordsEngineToolCall(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
runID, err := AgentRunService.RecordEngineRun(db, EngineAgentRunInput{
ConversationID: 1, AIAgentID: 2, AgentRevisionID: 3, EngineCode: "autonomous", Status: "completed", StartedAt: now,
StepType: "model", StepCode: "chat_completion", StepInputPreview: "authorization=Bearer-secret", ToolCalls: []EngineToolCallInput{{
ToolCode: "knowledge/search", RiskLevel: "read", Status: "completed", ArgumentsPreview: `{"token":"abc123","query":"refund"}`, ResultPreview: "policy text",
}},
})
if err != nil {
t.Fatalf("RecordEngineRun returned error: %v", err)
}
_, steps, toolCalls := AgentRunService.GetDetail(runID)
if len(toolCalls) != 1 || toolCalls[0].ToolCode != "knowledge/search" || toolCalls[0].AgentStepID <= 0 {
t.Fatalf("unexpected tool audit: %#v", toolCalls)
}
if strings.Contains(toolCalls[0].ArgumentsPreview, "abc123") || len(steps) != 1 || strings.Contains(steps[0].InputPreview, "Bearer-secret") {
t.Fatalf("sensitive audit data leaked: steps=%#v calls=%#v", steps, toolCalls)
}
}
func TestAgentRunServiceRecordsHybridPlaybookResume(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now().Add(-time.Minute)
run := &models.AgentRun{EngineCode: "hybrid", Status: "interrupted", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create hybrid run: %v", err)
}
if err := AgentRunService.RecordHybridPlaybookResume(db, run.ID, 33, "completed", "已完成工单登记。"); err != nil {
t.Fatalf("RecordHybridPlaybookResume returned error: %v", err)
}
item, steps, _ := AgentRunService.GetDetail(run.ID)
if item == nil || item.Status != "completed" || item.EndedAt == nil {
t.Fatalf("expected completed hybrid run, got %#v", item)
}
if len(steps) != 1 || steps[0].StepCode != "playbook_resume" || steps[0].WorkflowRunID != 33 || steps[0].OutputPreview != "已完成工单登记。" {
t.Fatalf("unexpected playbook resume step: %#v", steps)
}
}
func TestAgentRunServiceSavesQualityFeedbackPerRun(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
now := time.Now()
run := &models.AgentRun{AIAgentID: 4, EngineCode: "autonomous", Status: "completed", StartedAt: now, CreatedAt: now, UpdatedAt: now}
if err := db.Create(run).Error; err != nil {
t.Fatalf("create agent run: %v", err)
}
operator := &dto.AuthPrincipal{UserID: 7, Username: "reviewer"}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported, Comment: "issue resolved",
}, operator); err != nil {
t.Fatalf("save quality feedback: %v", err)
}
if err := AgentRunService.SaveQualityFeedback(request.SaveAgentRunQualityFeedbackRequest{
AgentRunID: run.ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported, Comment: "missing evidence",
}, operator); err != nil {
t.Fatalf("update quality feedback: %v", err)
}
feedback := AgentRunService.GetQualityFeedback(run.ID)
if feedback == nil || feedback.ResolutionStatus != enums.AgentRunResolutionStatusUnresolved || feedback.EvidenceStatus != enums.AgentRunEvidenceStatusUnsupported || feedback.Comment != "missing evidence" || feedback.UpdateUserName != "reviewer" {
t.Fatalf("unexpected quality feedback: %#v", feedback)
}
}
func TestAgentRunServiceAggregatesCrossEngineMetrics(t *testing.T) {
db := setupAgentRunServiceTestDB(t)
base := time.Now().Add(-time.Minute)
runs := []models.AgentRun{
{AIAgentID: 8, EngineCode: "autonomous", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(100 * time.Millisecond)), PromptTokens: 10, CompletionTokens: 5, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 8, EngineCode: "workflow", Status: "failed", StartedAt: base, EndedAt: timePtr(base.Add(300 * time.Millisecond)), PromptTokens: 8, CompletionTokens: 2, CreatedAt: base, UpdatedAt: base},
{AIAgentID: 9, EngineCode: "hybrid", Status: "completed", StartedAt: base, EndedAt: timePtr(base.Add(900 * time.Millisecond)), CreatedAt: base, UpdatedAt: base},
}
for index := range runs {
if err := db.Create(&runs[index]).Error; err != nil {
t.Fatalf("create run: %v", err)
}
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[0].ID, Status: "completed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentStep{AgentRunID: runs[1].ID, Status: "failed", StartedAt: base, CreatedAt: base}).Error; err != nil {
t.Fatalf("create step: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[0].ID, Status: "completed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create completed tool call: %v", err)
}
if err := db.Create(&models.AgentToolCall{AgentRunID: runs[1].ID, Status: "failed", CreatedAt: base}).Error; err != nil {
t.Fatalf("create failed tool call: %v", err)
}
if err := db.Create(&models.Conversation{AIAgentID: 8}).Error; err != nil {
t.Fatalf("create conversation: %v", err)
}
handoffAt := base
if err := db.Create(&models.Conversation{AIAgentID: 8, HandoffAt: &handoffAt}).Error; err != nil {
t.Fatalf("create handoff conversation: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[0].ID, CheckPointID: "metrics-resolved", Status: "resolved", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create resolved interrupt: %v", err)
}
if err := db.Create(&models.ConversationInterrupt{AgentRunID: runs[1].ID, CheckPointID: "metrics-cancelled", Status: "cancelled", ResumeCount: 1, CreatedAt: base, UpdatedAt: base}).Error; err != nil {
t.Fatalf("create cancelled interrupt: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[0].ID, ResolutionStatus: enums.AgentRunResolutionStatusResolved, EvidenceStatus: enums.AgentRunEvidenceStatusSupported}).Error; err != nil {
t.Fatalf("create resolved feedback: %v", err)
}
if err := db.Create(&models.AgentRunQualityFeedback{AgentRunID: runs[1].ID, ResolutionStatus: enums.AgentRunResolutionStatusUnresolved, EvidenceStatus: enums.AgentRunEvidenceStatusUnsupported}).Error; err != nil {
t.Fatalf("create unresolved feedback: %v", err)
}
metrics := AgentRunService.GetMetrics(8)
if metrics.TotalRuns != 2 || metrics.CompletedRuns != 1 || metrics.FailedRuns != 1 || metrics.CompletionRate != 0.5 {
t.Fatalf("unexpected run metrics: %#v", metrics)
}
if metrics.AverageDurationMS != 200 || metrics.P95DurationMS != 300 || metrics.ToolCalls != 2 || metrics.ToolSuccessRate != 0.5 || metrics.AverageSteps != 1 {
t.Fatalf("unexpected aggregate metrics: %#v", metrics)
}
if metrics.PromptTokens != 18 || metrics.CompletionTokens != 7 {
t.Fatalf("unexpected token metrics: %#v", metrics)
}
if metrics.HandoffRate != 0.5 || metrics.KnowledgeFallbackRate != 0 {
t.Fatalf("unexpected business metrics: %#v", metrics)
}
if metrics.ResumedInterrupts != 2 || metrics.ResolvedInterrupts != 1 || metrics.InterruptRecoveryRate != 0.5 {
t.Fatalf("unexpected interrupt recovery metrics: %#v", metrics)
}
if metrics.ReviewedRuns != 2 || metrics.ResolvedRuns != 1 || metrics.ResolutionRate != 0.5 || metrics.UnsupportedEvidenceRuns != 1 || metrics.UnsupportedEvidenceRate != 0.5 {
t.Fatalf("unexpected quality metrics: %#v", metrics)
}
comparisons := AgentRunService.GetEngineComparisons(8)
if len(comparisons) != 2 || comparisons[0].EngineCode != "autonomous" || comparisons[1].EngineCode != "workflow" {
t.Fatalf("unexpected engine comparison groups: %#v", comparisons)
}
if comparisons[0].Metrics.TotalRuns != 1 || comparisons[0].Metrics.ResolutionRate != 1 || comparisons[1].Metrics.TotalRuns != 1 || comparisons[1].Metrics.UnsupportedEvidenceRate != 1 {
t.Fatalf("unexpected engine comparison metrics: %#v", comparisons)
}
}
func timePtr(value time.Time) *time.Time { return &value }
func setupAgentRunServiceTestDB(t *testing.T) *gorm.DB {
t.Helper()
dbName := strings.NewReplacer("/", "_", " ", "_").Replace(t.Name())
db, err := gorm.Open(sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), &gorm.Config{
NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true},
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
if err := db.AutoMigrate(&models.AgentRevision{}, &models.AgentRun{}, &models.AgentStep{}, &models.AgentToolCall{}, &models.AgentRunQualityFeedback{}, &models.Conversation{}, &models.ConversationInterrupt{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
return db
}
@@ -0,0 +1,81 @@
package services
import (
"strings"
"time"
"agent-desk/internal/models"
"agent-desk/internal/repositories"
"github.com/mlogclub/simple/sqls"
)
const (
agentToolInvocationStatusRunning = "running"
agentToolInvocationStatusCompleted = "completed"
agentToolInvocationStatusFailed = "failed"
)
var AgentToolInvocationService = newAgentToolInvocationService()
type AgentToolInvocationClaim struct {
Item *models.AgentToolInvocation
Completed bool
Acquired bool
}
type agentToolInvocationService struct{}
func newAgentToolInvocationService() *agentToolInvocationService {
return &agentToolInvocationService{}
}
// Claim obtains the persistent idempotency boundary. A completed invocation
// can be returned to callers; an in-flight invocation is never executed again.
func (s *agentToolInvocationService) Claim(conversationID, aiAgentID int64, toolCode, idempotencyKey string) (*AgentToolInvocationClaim, error) {
toolCode = strings.TrimSpace(toolCode)
idempotencyKey = strings.TrimSpace(idempotencyKey)
if conversationID <= 0 || toolCode == "" || idempotencyKey == "" {
return nil, nil
}
if item := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); item != nil {
if item.Status == agentToolInvocationStatusCompleted {
return &AgentToolInvocationClaim{Item: item, Completed: true}, nil
}
if item.Status == agentToolInvocationStatusRunning {
return &AgentToolInvocationClaim{Item: item}, nil
}
if err := repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusRunning, "error_message": "", "updated_at": time.Now()}); err != nil {
return nil, err
}
item.Status, item.ErrorMessage = agentToolInvocationStatusRunning, ""
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
item := &models.AgentToolInvocation{ConversationID: conversationID, AIAgentID: aiAgentID, ToolCode: toolCode, IdempotencyKey: idempotencyKey, Status: agentToolInvocationStatusRunning}
if err := repositories.AgentToolInvocationRepository.Create(sqls.DB(), item); err != nil {
// A concurrent caller may have created the unique invocation first.
if existing := repositories.AgentToolInvocationRepository.GetByIdempotencyKey(sqls.DB(), conversationID, toolCode, idempotencyKey); existing != nil {
return &AgentToolInvocationClaim{Item: existing, Completed: existing.Status == agentToolInvocationStatusCompleted}, nil
}
return nil, err
}
return &AgentToolInvocationClaim{Item: item, Acquired: true}, nil
}
func (s *agentToolInvocationService) Complete(item *models.AgentToolInvocation, resultData string) error {
if item == nil || item.ID <= 0 {
return nil
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusCompleted, "result_data": resultData, "error_message": "", "updated_at": time.Now()})
}
func (s *agentToolInvocationService) Fail(item *models.AgentToolInvocation, cause error) error {
if item == nil || item.ID <= 0 {
return nil
}
message := ""
if cause != nil {
message = cause.Error()
}
return repositories.AgentToolInvocationRepository.Updates(sqls.DB(), item.ID, map[string]any{"status": agentToolInvocationStatusFailed, "error_message": message, "updated_at": time.Now()})
}
@@ -0,0 +1,65 @@
package services
import (
"strings"
"testing"
"agent-desk/internal/models"
"github.com/glebarez/sqlite"
"github.com/mlogclub/simple/sqls"
"gorm.io/gorm"
"gorm.io/gorm/schema"
)
func TestAgentToolInvocationServiceReusesCompletedInvocation(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || first == nil || first.Item == nil || first.Completed {
t.Fatalf("first claim = %#v, err=%v", first, err)
}
if err := AgentToolInvocationService.Complete(first.Item, `{"ticketId":40}`); err != nil {
t.Fatalf("complete invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(10, 20, "graph/create_ticket_with_confirmation", "message:30:node:create")
if err != nil || second == nil || !second.Completed || second.Item.ResultData != `{"ticketId":40}` {
t.Fatalf("second claim = %#v, err=%v", second, err)
}
}
func TestAgentToolInvocationServiceAllowsFailedInvocationRetry(t *testing.T) {
db, err := gorm.Open(sqlite.Open("file:"+strings.ReplaceAll(t.Name(), "/", "_")+"?mode=memory&cache=shared"), &gorm.Config{NamingStrategy: schema.NamingStrategy{TablePrefix: "t_", SingularTable: true}})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&models.AgentToolInvocation{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
sqls.SetDB(db)
first, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil {
t.Fatalf("first claim: %v", err)
}
if err := AgentToolInvocationService.Fail(first.Item, errTestToolInvocation); err != nil {
t.Fatalf("fail invocation: %v", err)
}
second, err := AgentToolInvocationService.Claim(11, 21, "graph/handoff_to_human", "message:31:node:handoff")
if err != nil || second == nil || second.Completed || second.Item.Status != agentToolInvocationStatusRunning || second.Item.ErrorMessage != "" {
t.Fatalf("retry claim = %#v, err=%v", second, err)
}
}
var errTestToolInvocation = &toolInvocationTestError{}
type toolInvocationTestError struct{}
func (e *toolInvocationTestError) Error() string { return "tool failed" }
+276 -10
View File
@@ -6,6 +6,7 @@ import (
"strings"
"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,
+140 -8
View File
@@ -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()
+120
View File
@@ -0,0 +1,120 @@
package services
import (
"context"
"encoding/json"
"fmt"
"strings"
aitooling "agent-desk/internal/ai/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/dto"
"agent-desk/internal/pkg/dto/request"
"agent-desk/internal/pkg/toolx"
)
// BusinessToolExecutor is the write boundary for built-in business tools.
// Autonomous mode deliberately does not expose it; deterministic Playbooks
// invoke it only after their human-confirm node has completed.
var BusinessToolExecutor = newBusinessToolExecutor(aitooling.DefaultRegistry)
type BusinessToolInput struct {
Conversation models.Conversation
AIAgent models.AIAgent
ToolCode string
Arguments map[string]any
IdempotencyKey string
Confirmed bool
}
type BusinessToolResult struct {
Definition aitooling.Definition
ResultData string
Reused bool
}
type businessToolExecutor struct {
registry *aitooling.Registry
}
func newBusinessToolExecutor(registry *aitooling.Registry) *businessToolExecutor {
return &businessToolExecutor{registry: registry}
}
func (e *businessToolExecutor) Execute(_ context.Context, input BusinessToolInput) (*BusinessToolResult, error) {
toolCode := toolx.NormalizeToolCodeAlias(strings.TrimSpace(input.ToolCode))
definition, err := e.registry.Resolve(toolCode)
if err != nil {
return nil, err
}
if err := e.registry.Authorize(definition, aitooling.Policy{AllowedToolCodes: []string{definition.Code}, AllowedRiskLevels: []string{aitooling.RiskLevelWrite}, Confirmed: input.Confirmed}); err != nil {
return nil, err
}
if input.Conversation.ID <= 0 || strings.TrimSpace(input.IdempotencyKey) == "" {
return nil, fmt.Errorf("business tool invocation requires conversation and idempotency key")
}
claim, err := AgentToolInvocationService.Claim(input.Conversation.ID, input.AIAgent.ID, definition.Code, input.IdempotencyKey)
if err != nil {
return nil, err
}
if claim == nil || claim.Item == nil {
return nil, fmt.Errorf("business tool invocation could not be claimed")
}
if claim.Completed {
return &BusinessToolResult{Definition: definition, ResultData: claim.Item.ResultData, Reused: true}, nil
}
if !claim.Acquired {
return nil, fmt.Errorf("business tool invocation is already running: %s", definition.Code)
}
resultData, err := e.execute(definition.Code, input)
if err != nil {
_ = AgentToolInvocationService.Fail(claim.Item, err)
return nil, err
}
if err := AgentToolInvocationService.Complete(claim.Item, resultData); err != nil {
return nil, err
}
return &BusinessToolResult{Definition: definition, ResultData: resultData}, nil
}
func (e *businessToolExecutor) execute(toolCode string, input BusinessToolInput) (string, error) {
switch toolCode {
case toolx.GraphCreateTicketConfirm.Code:
item, err := TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
ConversationID: input.Conversation.ID,
Title: businessToolString(input.Arguments["title"]),
Description: businessToolString(input.Arguments["description"]),
}, businessToolPrincipal(input.AIAgent))
if err != nil {
return "", err
}
return businessToolJSON(map[string]any{"ticketId": item.ID, "ticketNo": item.TicketNo, "created": true})
case toolx.GraphHandoffConversation.Code:
result, err := ConversationHumanDispatchService.HandoffByAIWithRequestID(input.Conversation.ID, input.AIAgent, businessToolString(input.Arguments["reason"]), input.IdempotencyKey)
if err != nil {
return "", err
}
return businessToolJSON(map[string]any{"decision": result.Decision, "teamId": result.TeamID, "assigneeId": result.AssigneeID, "message": result.Message})
default:
return "", fmt.Errorf("business tool is not executable: %s", toolCode)
}
}
func businessToolString(value any) string {
text, _ := value.(string)
return strings.TrimSpace(text)
}
func businessToolPrincipal(agent models.AIAgent) *dto.AuthPrincipal {
name := strings.TrimSpace(agent.Name)
if name == "" {
name = "AI"
}
return &dto.AuthPrincipal{Username: name, Nickname: name}
}
func businessToolJSON(value any) (string, error) {
data, err := json.Marshal(value)
return string(data), err
}
+71 -20
View File
@@ -105,17 +105,49 @@ func (s *channelService) UpdateChannel(req request.UpdateChannelRequest, operato
if err != nil {
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
}
+118 -1
View File
@@ -48,6 +48,123 @@ func TestChannelServiceAllowsAgentWithPublishedWorkflow(t *testing.T) {
}
}
func TestChannelServiceStoresAIAgentRolloutPercent(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 25,
Name: "灰度渠道", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil || item.AIAgentRolloutPercent != 25 {
t.Fatalf("expected persisted rollout percent, item=%#v err=%v", item, err)
}
if _, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 101,
Name: "错误灰度渠道", Status: int(enums.StatusOk),
}, channelServiceTestOperator()); err == nil {
t.Fatal("expected invalid rollout percent to be rejected")
}
}
func TestChannelServiceRollsBackPreviousAIAgentRolloutPercent(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, AIAgentRolloutPercent: 20,
Name: "渠道灰度回滚", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil {
t.Fatalf("create channel: %v", err)
}
if err := db.Model(&models.Channel{}).Where("id = ?", channel.ID).Update("previous_ai_agent_rollout_percent", 100).Error; err != nil {
t.Fatalf("set previous rollout: %v", err)
}
operator := channelServiceTestOperator()
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
t.Fatalf("RollbackChannelAIAgentRollout: %v", err)
}
updated := ChannelService.Get(channel.ID)
if updated == nil || updated.AIAgentRolloutPercent != 100 || updated.PreviousAIAgentRolloutPercent != 20 {
t.Fatalf("unexpected channel rollout rollback: %#v", updated)
}
if err := ChannelService.RollbackChannelAIAgentRollout(channel.ID, operator); err != nil {
t.Fatalf("second RollbackChannelAIAgentRollout: %v", err)
}
updated = ChannelService.Get(channel.ID)
if updated == nil || updated.AIAgentRolloutPercent != 20 || updated.PreviousAIAgentRolloutPercent != 100 {
t.Fatalf("unexpected channel rollout redo: %#v", updated)
}
}
func TestChannelServiceRejectsUnpublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("runtime_mode", enums.AIAgentRuntimeModeAutonomous).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb,
AIAgentID: agent.ID,
Name: "官网客服",
Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "must be published") {
t.Fatalf("expected unpublished autonomous runtime error, got %v", err)
}
}
func TestChannelServiceAcceptsPublishedAutonomousRuntime(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 1001)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeAutonomous,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set autonomous runtime mode: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "自主客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for autonomous runtime: item=%#v err=%v", item, err)
}
}
func TestChannelServiceRequiresBothHybridPublicationArtifacts(t *testing.T) {
db := setupChannelServiceTestDB(t)
agent := createChannelServiceTestAgent(t, db, 0)
revision := &models.AgentRevision{AgentID: agent.ID, Revision: 1, Status: enums.StatusOk}
if err := db.Create(revision).Error; err != nil {
t.Fatalf("create agent revision: %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Updates(map[string]any{
"runtime_mode": enums.AIAgentRuntimeModeHybrid,
"published_revision_id": revision.ID,
}).Error; err != nil {
t.Fatalf("set hybrid runtime mode: %v", err)
}
_, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err == nil || !strings.Contains(err.Error(), "hybrid ai agent") {
t.Fatalf("expected hybrid publication error, got %v", err)
}
if err := db.Model(&models.AIAgent{}).Where("id = ?", agent.ID).Update("workflow_version_id", 1001).Error; err != nil {
t.Fatalf("set workflow version: %v", err)
}
item, err := ChannelService.CreateChannel(request.CreateChannelRequest{
ChannelType: enums.ChannelTypeWeb, AIAgentID: agent.ID, Name: "混合客服已发布", Status: int(enums.StatusOk),
}, channelServiceTestOperator())
if err != nil || item == nil {
t.Fatalf("create channel for hybrid runtime: item=%#v err=%v", item, err)
}
}
func setupChannelServiceTestDB(t *testing.T) *gorm.DB {
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
+167
View File
@@ -0,0 +1,167 @@
"use client"
import { useEffect, useState } from "react"
import { AlertTriangleIcon, BotMessageSquareIcon, Clock3Icon, WorkflowIcon, WrenchIcon } from "lucide-react"
import { toast } from "sonner"
import { DashboardListPage } from "@/components/dashboard/list"
import { JsonTreeViewer } from "@/components/json-tree-viewer"
import { OptionCombobox } from "@/components/option-combobox"
import { ProjectDialog } from "@/components/project-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { fetchAgentRun, fetchAgentRunMetrics, fetchAgentRuns, fetchAIWorkflowRun, fetchAgentRunEngineComparisons, saveAgentRunQualityFeedback, type AgentRun, type AgentRunEngineComparison, type AgentRunMetrics, type AgentStep, type AgentToolCall, type AIWorkflowRun } from "@/lib/api/admin"
import { formatDateTime } from "@/lib/utils"
import { useI18n } from "@/i18n/provider"
import { WorkflowRunAuditGraph } from "../ai-workflow-runs/_components/workflow-run-audit-graph"
function statusVariant(status: string) {
if (status === "failed") return "destructive" as const
if (status === "interrupted") return "outline" as const
if (status === "completed") return "default" as const
return "secondary" as const
}
export default function DashboardAgentRunsPage() {
const t = useI18n()
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [run, setRun] = useState<AgentRun | null>(null)
const [workflowAuditOpen, setWorkflowAuditOpen] = useState(false)
const [workflowAuditLoading, setWorkflowAuditLoading] = useState(false)
const [workflowRun, setWorkflowRun] = useState<AIWorkflowRun | null>(null)
const [metrics, setMetrics] = useState<AgentRunMetrics | null>(null)
const [comparisons, setComparisons] = useState<AgentRunEngineComparison[]>([])
useEffect(() => {
void fetchAgentRunMetrics().then(setMetrics).catch(() => setMetrics(null))
void fetchAgentRunEngineComparisons().then(setComparisons).catch(() => setComparisons([]))
}, [])
async function openDetail(id: number) {
setOpen(true)
setLoading(true)
try {
setRun(await fetchAgentRun(id))
} catch (error) {
toast.error(error instanceof Error ? error.message : t("agentRun.loadDetailFailed"))
setOpen(false)
} finally {
setLoading(false)
}
}
async function openWorkflowAudit(id: number) {
if (id <= 0) return
setWorkflowAuditOpen(true)
setWorkflowAuditLoading(true)
try {
setWorkflowRun(await fetchAIWorkflowRun(id))
} catch (error) {
toast.error(error instanceof Error ? error.message : "加载 Workflow 节点审计失败")
setWorkflowAuditOpen(false)
} finally {
setWorkflowAuditLoading(false)
}
}
return (
<>
{metrics ? <div className="grid grid-cols-2 gap-px border-b bg-border sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-10">
<Metric label="运行完成率" value={`${Math.round(metrics.completionRate * 100)}%`} detail={`${metrics.completedRuns}/${metrics.totalRuns}`} />
<Metric label="解决率" value={metrics.reviewedRuns ? `${Math.round(metrics.resolutionRate * 100)}%` : "-"} detail={`${metrics.resolvedRuns}/${metrics.reviewedRuns} 已质检`} />
<Metric label="无依据率" value={metrics.reviewedRuns ? `${Math.round(metrics.unsupportedEvidenceRate * 100)}%` : "-"} detail={`${metrics.unsupportedEvidenceRuns}/${metrics.reviewedRuns} 已质检`} />
<Metric label="工具成功率" value={metrics.toolCalls ? `${Math.round(metrics.toolSuccessRate * 100)}%` : "-"} detail={`${metrics.toolCalls} 次调用`} />
<Metric label="平均步骤" value={metrics.averageSteps.toFixed(1)} detail={`${metrics.totalRuns} 次运行`} />
<Metric label="P95 时延" value={`${metrics.p95DurationMs} ms`} detail={`平均 ${metrics.averageDurationMs} ms`} />
<Metric label="Token" value={`${metrics.promptTokens + metrics.completionTokens}`} detail={`${metrics.promptTokens}/${metrics.completionTokens}`} />
<Metric label="转人工率" value={`${Math.round(metrics.handoffRate * 100)}%`} detail="已转人工会话" />
<Metric label="知识兜底率" value={`${Math.round(metrics.knowledgeFallbackRate * 100)}%`} detail="证据不足或检索失败" />
<Metric label="中断恢复率" value={metrics.resumedInterrupts ? `${Math.round(metrics.interruptRecoveryRate * 100)}%` : "-"} detail={`${metrics.resolvedInterrupts}/${metrics.resumedInterrupts}`} />
</div> : null}
{comparisons.length > 0 ? <section className="border-b"><div className="px-4 py-3 text-sm font-medium"></div><div className="overflow-x-auto"><table className="w-full min-w-[760px] text-sm"><thead className="border-y bg-muted/30 text-left text-xs text-muted-foreground"><tr><th className="px-4 py-2 font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium"></th><th className="px-4 py-2 text-right font-medium">P95</th><th className="px-4 py-2 text-right font-medium">Token</th></tr></thead><tbody>{comparisons.map((item) => <tr key={item.engineCode} className="border-b last:border-0"><td className="px-4 py-2 font-medium">{item.engineCode}</td><td className="px-4 py-2 text-right">{item.metrics.totalRuns}</td><td className="px-4 py-2 text-right">{Math.round(item.metrics.completionRate * 100)}%</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.resolutionRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.reviewedRuns ? `${Math.round(item.metrics.unsupportedEvidenceRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.toolCalls ? `${Math.round(item.metrics.toolSuccessRate * 100)}%` : "-"}</td><td className="px-4 py-2 text-right">{item.metrics.p95DurationMs} ms</td><td className="px-4 py-2 text-right">{item.metrics.promptTokens + item.metrics.completionTokens}</td></tr>)}</tbody></table></div></section> : null}
<DashboardListPage<AgentRun>
filters={[
{ name: "conversationId", label: t("agentRun.conversation"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "aiAgentId", label: t("agentRun.agent"), defaultValue: "", valueType: "number", className: "w-full sm:w-40" },
{ name: "engineCode", label: t("agentRun.engine"), defaultValue: "", className: "w-full sm:w-40" },
{ name: "status", label: t("agentRun.status"), defaultValue: "", className: "w-full sm:w-40" },
]}
fetchList={fetchAgentRuns}
getItemId={(item) => item.id}
getRowClassName={() => "cursor-pointer"}
onRowClick={(item) => void openDetail(item.id)}
columns={[
{ key: "startedAt", label: t("agentRun.startedAt"), className: "w-42 text-xs text-muted-foreground", render: (item) => formatDateTime(item.startedAt || item.createdAt) },
{ key: "engine", label: t("agentRun.engine"), className: "w-32", render: (item) => item.engineCode || "-" },
{ key: "agent", label: t("agentRun.agent"), className: "w-28", render: (item) => `#${item.aiAgentId || "-"}` },
{ key: "conversation", label: t("agentRun.conversation"), className: "w-28", render: (item) => `#${item.conversationId || "-"}` },
{ key: "status", label: t("agentRun.status"), className: "w-30", render: (item) => <Badge variant={statusVariant(item.status)}>{item.status || "-"}</Badge> },
{ key: "duration", label: t("agentRun.duration"), className: "w-24 text-right", render: (item) => `${item.durationMs || 0} ms` },
{ key: "tokens", label: t("agentRun.tokens"), className: "w-28 text-right", render: (item) => `${item.promptTokens || 0}/${item.completionTokens || 0}` },
{ key: "error", label: t("agentRun.error"), className: "w-72 max-w-72", render: (item) => item.errorMessage ? <span className="block truncate text-xs text-destructive" title={item.errorMessage}>{item.errorMessage}</span> : "-" },
]}
labels={{ refresh: t("agentRun.refresh"), query: t("agentRun.query"), loading: t("agentRun.loading"), empty: t("agentRun.empty"), loadFailed: t("agentRun.loadFailed") }}
/>
<AgentRunDetailDialog open={open} loading={loading} run={run} onOpenWorkflowAudit={openWorkflowAudit} onQualityFeedbackSaved={(id) => void openDetail(id)} onOpenChange={(next) => { setOpen(next); if (!next) setRun(null) }} t={t} />
<WorkflowAuditDialog open={workflowAuditOpen} loading={workflowAuditLoading} run={workflowRun} onOpenChange={(next) => { setWorkflowAuditOpen(next); if (!next) setWorkflowRun(null) }} />
</>
)
}
function Metric({ label, value, detail }: { label: string; value: string; detail: string }) { return <div className="bg-background px-4 py-3"><div className="text-xs text-muted-foreground">{label}</div><div className="mt-1 text-lg font-semibold">{value}</div><div className="text-xs text-muted-foreground">{detail}</div></div> }
function AgentRunDetailDialog({ open, loading, run, onOpenChange, onOpenWorkflowAudit, onQualityFeedbackSaved, t }: { open: boolean; loading: boolean; run: AgentRun | null; onOpenChange: (open: boolean) => void; onOpenWorkflowAudit: (workflowRunId: number) => void; onQualityFeedbackSaved: (agentRunId: number) => void; t: (key: string) => string }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><BotMessageSquareIcon className="size-4" />{t("agentRun.detailTitle")}</span>} description={run ? `Run #${run.id}` : t("agentRun.detailDescription")} footer={<Button variant="outline" onClick={() => onOpenChange(false)}>{t("agentRun.close")}</Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">{t("agentRun.loadingDetail")}</div> : run ? <div className="space-y-4">
<div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label={t("agentRun.engine")} value={run.engineCode} /><Meta label={t("agentRun.status")} value={run.status} /><Meta label={t("agentRun.agent")} value={`#${run.aiAgentId}`} /><Meta label={t("agentRun.revision")} value={`#${run.agentRevisionId || "-"}`} /><Meta label={t("agentRun.duration")} value={`${run.durationMs || 0} ms`} /><Meta label={t("agentRun.tokens")} value={`${run.promptTokens || 0}/${run.completionTokens || 0}`} /></div>
{run.workflowRunId > 0 ? <section className="flex items-center justify-between gap-3 border px-3 py-2"><div><div className="text-sm font-medium"> Playbook </div><div className="text-xs text-muted-foreground">Workflow Run #{run.workflowRunId} </div></div><Button type="button" variant="outline" size="sm" onClick={() => onOpenWorkflowAudit(run.workflowRunId)}><WorkflowIcon /></Button></section> : null}
<QualityFeedbackPanel run={run} onSaved={onQualityFeedbackSaved} />
{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}
<Preview title={t("agentRun.trace")} raw={run.traceData} />
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.steps")}</h3>{(run.steps ?? []).map((step) => <StepBlock key={step.id} step={step} t={t} />)}{!run.steps?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptySteps")}</p> : null}</section>
<section className="space-y-2"><h3 className="text-sm font-medium">{t("agentRun.toolCalls")}</h3>{(run.toolCalls ?? []).map((call) => <ToolCallBlock key={call.id} call={call} t={t} />)}{!run.toolCalls?.length ? <p className="text-sm text-muted-foreground">{t("agentRun.emptyToolCalls")}</p> : null}</section>
</div> : <div className="py-10 text-sm text-muted-foreground">{t("agentRun.notFound")}</div>}
</ProjectDialog>
}
function QualityFeedbackPanel({ run, onSaved }: { run: AgentRun; onSaved: (agentRunId: number) => void }) {
const [resolutionStatus, setResolutionStatus] = useState<"unknown" | "resolved" | "unresolved">("unknown")
const [evidenceStatus, setEvidenceStatus] = useState<"unknown" | "supported" | "unsupported">("unknown")
const [comment, setComment] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
setResolutionStatus(run.qualityFeedback?.resolutionStatus ?? "unknown")
setEvidenceStatus(run.qualityFeedback?.evidenceStatus ?? "unknown")
setComment(run.qualityFeedback?.comment ?? "")
}, [run.id, run.qualityFeedback])
async function save() {
setSaving(true)
try {
await saveAgentRunQualityFeedback({ agentRunId: run.id, resolutionStatus, evidenceStatus, comment })
toast.success("质检结果已保存")
onSaved(run.id)
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存质检结果失败")
} finally {
setSaving(false)
}
}
return <section className="space-y-3 border p-3"><div><h3 className="text-sm font-medium"></h3><p className="text-xs text-muted-foreground"></p></div><div className="grid gap-3 sm:grid-cols-2"><OptionCombobox value={resolutionStatus} placeholder="选择解决情况" options={[{ value: "unknown", label: "解决情况:未判断" }, { value: "resolved", label: "解决情况:已解决" }, { value: "unresolved", label: "解决情况:未解决" }]} onChange={(value) => setResolutionStatus(value === "resolved" || value === "unresolved" ? value : "unknown")} /><OptionCombobox value={evidenceStatus} placeholder="选择依据情况" options={[{ value: "unknown", label: "依据情况:未判断" }, { value: "supported", label: "依据情况:有依据" }, { value: "unsupported", label: "依据情况:无依据" }]} onChange={(value) => setEvidenceStatus(value === "supported" || value === "unsupported" ? value : "unknown")} /></div><Textarea rows={3} value={comment} onChange={(event) => setComment(event.target.value)} placeholder="质检备注" /><div className="flex items-center justify-between gap-3"><span className="text-xs text-muted-foreground">{run.qualityFeedback?.updatedAt ? `最近标注:${run.qualityFeedback.updatedAt}` : "尚未标注"}</span><Button type="button" size="sm" disabled={saving} onClick={save}></Button></div></section>
}
function WorkflowAuditDialog({ open, loading, run, onOpenChange }: { open: boolean; loading: boolean; run: AIWorkflowRun | null; onOpenChange: (open: boolean) => void }) {
return <ProjectDialog open={open} onOpenChange={onOpenChange} size="xl" allowFullscreen defaultFullscreen title={<span className="flex items-center gap-2"><WorkflowIcon className="size-4" />Workflow </span>} description={run ? `Workflow Run #${run.id}` : "加载关联 Playbook 的节点审计"} footer={<Button variant="outline" onClick={() => onOpenChange(false)}></Button>}>
{loading ? <div className="py-10 text-sm text-muted-foreground">...</div> : run ? <div className="space-y-3"><div className="flex flex-wrap gap-2 rounded-md border bg-muted/20 px-3 py-2 text-xs"><Meta label="状态" value={run.statusName} /><Meta label="Workflow" value={run.workflowName || `#${run.workflowId}`} /><Meta label="版本" value={`v${run.workflowVersion || "-"}`} /><Meta label="时延" value={`${run.durationMs || 0} ms`} /></div>{run.errorMessage ? <div className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive"><AlertTriangleIcon className="size-4 shrink-0" />{run.errorMessage}</div> : null}<WorkflowRunAuditGraph run={run} /></div> : <div className="py-10 text-sm text-muted-foreground"> Workflow Run</div>}
</ProjectDialog>
}
function Meta({ label, value }: { label: string; value: string }) { return <span className="inline-flex items-center gap-1 rounded-md border bg-background px-2 py-1"><span className="text-muted-foreground">{label}</span><span className="font-medium">{value || "-"}</span></span> }
function StepBlock({ step, t }: { step: AgentStep; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><Clock3Icon className="size-4 text-muted-foreground" /><span className="font-medium">{step.stepCode || step.stepType}</span><Badge variant={statusVariant(step.status)}>{step.status}</Badge><span className="text-xs text-muted-foreground">{step.durationMs || 0} ms</span></div>{step.errorMessage ? <p className="mt-2 text-xs text-destructive">{step.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.input")} raw={step.inputPreview} /><Preview title={t("agentRun.output")} raw={step.outputPreview} /></div></div> }
function ToolCallBlock({ call, t }: { call: AgentToolCall; t: (key: string) => string }) { return <div className="rounded-md border p-3"><div className="flex flex-wrap items-center gap-2"><WrenchIcon className="size-4 text-muted-foreground" /><span className="font-medium">{call.toolCode}</span><Badge variant={statusVariant(call.status)}>{call.status}</Badge><span className="text-xs text-muted-foreground">{call.riskLevel}</span></div>{call.errorMessage ? <p className="mt-2 text-xs text-destructive">{call.errorMessage}</p> : null}<div className="mt-3 grid gap-3 lg:grid-cols-2"><Preview title={t("agentRun.arguments")} raw={call.argumentsPreview} /><Preview title={t("agentRun.result")} raw={call.resultPreview} /></div></div> }
function Preview({ title, raw }: { title: string; raw: string }) { const value = parseJSON(raw); return <div className="min-w-0"><div className="mb-1 text-xs text-muted-foreground">{title}</div>{value !== null ? <JsonTreeViewer value={value} collapsed={2} /> : raw?.trim() ? <pre className="max-h-52 overflow-auto rounded-md border bg-muted/20 p-2 text-xs whitespace-pre-wrap break-all">{raw}</pre> : <div className="rounded-md border bg-muted/20 px-2 py-1.5 text-xs text-muted-foreground">-</div>}</div> }
function parseJSON(raw: string): unknown | null { try { return raw?.trim() ? JSON.parse(raw) : null } catch { return null } }
@@ -6,6 +6,7 @@ import {
GitBranchIcon,
HistoryIcon,
PlugIcon,
RotateCcwIcon,
SaveIcon,
SettingsIcon,
Trash2Icon,
@@ -36,25 +37,34 @@ import { Textarea } from "@/components/ui/textarea"
import {
createAIAgent,
fetchAIAgent,
fetchAIAgentRevisions,
fetchAIAgentWorkflow,
fetchAIConfigsAll,
fetchKnowledgeBasesAll,
fetchAIWorkflowDefaultDefinition,
fetchAIWorkflowNodeSpecs,
fetchAIWorkflowTemplates,
fetchAIWorkflowVersions,
fetchAgentTeamsAll,
fetchMCPCatalog,
fetchSkillDefinitionsAll,
publishAIAgentWorkflow,
publishAIAgent,
rollbackAIAgent,
rollbackAIAgentRollout,
saveAIAgentWorkflow,
updateAIAgent,
validateAIWorkflow,
type AIAgent,
type AgentRevision,
type AIConfig,
type AIWorkflowDefinition,
type AIWorkflowNodeSpec,
type AIWorkflowTemplate,
type AIWorkflowVersion,
type AdminAgentTeam,
type CreateAIAgentPayload,
type KnowledgeBase,
type MCPToolCatalogItem,
type MCPToolSourceType,
type SkillDefinition,
@@ -128,6 +138,7 @@ export function AIAgentConfigWorkbench({
const [activeSection, setActiveSection] = useState<SectionKey>("basic")
const [agent, setAgent] = useState<AIAgent | null>(null)
const [workflowVersions, setWorkflowVersions] = useState<AIWorkflowVersion[]>([])
const [agentRevisions, setAgentRevisions] = useState<AgentRevision[]>([])
const [nodeSpecs, setNodeSpecs] = useState<AIWorkflowNodeSpec[]>([])
const [loading, setLoading] = useState(true)
const [savingAgent, setSavingAgent] = useState(false)
@@ -137,28 +148,36 @@ export function AIAgentConfigWorkbench({
const [name, setName] = useState("")
const [description, setDescription] = useState("")
const [aiConfigId, setAIConfigId] = useState("")
const [runtimeMode, setRuntimeMode] = useState<"workflow" | "autonomous" | "hybrid">("autonomous")
const [serviceMode, setServiceMode] = useState(String(IMConversationServiceMode.AIFirst))
const [systemPrompt, setSystemPrompt] = useState("")
const [welcomeMessage, setWelcomeMessage] = useState("")
const [replyTimeoutSeconds, setReplyTimeoutSeconds] = useState("180")
const [rolloutPercent, setRolloutPercent] = useState("5")
const [handoffMode, setHandoffMode] = useState(String(AIAgentHandoffMode.WaitPool))
const [fallbackMode, setFallbackMode] = useState(String(AIAgentFallbackMode.NoAnswer))
const [fallbackMessage, setFallbackMessage] = useState("")
const [selectedTeamIds, setSelectedTeamIds] = useState<number[]>([])
const [selectedSkillIds, setSelectedSkillIds] = useState<number[]>([])
const [selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds] = useState<number[]>([])
const [directTools, setDirectTools] = useState<DirectToolItem[]>([])
const [definition, setDefinition] = useState<AIWorkflowDefinition>(fallbackDefinition)
const [workflowRevision, setWorkflowRevision] = useState(0)
const [workflowTemplates, setWorkflowTemplates] = useState<AIWorkflowTemplate[]>([])
const [selectedWorkflowTemplate, setSelectedWorkflowTemplate] = useState("")
const [aiConfigs, setAIConfigs] = useState<AIConfig[]>([])
const [agentTeams, setAgentTeams] = useState<AdminAgentTeam[]>([])
const [skills, setSkills] = useState<SkillDefinition[]>([])
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([])
const [toolCatalog, setToolCatalog] = useState<MCPToolCatalogItem[]>([])
const [teamToAdd, setTeamToAdd] = useState("")
const [skillToAdd, setSkillToAdd] = useState("")
const [knowledgeBaseToAdd, setKnowledgeBaseToAdd] = useState("")
const [directToolGroupToAdd, setDirectToolGroupToAdd] = useState("")
const [directToolToAdd, setDirectToolToAdd] = useState("")
const previousRolloutPercent = agent?.previousRolloutPercent ?? 0
useEffect(() => {
setCurrentAgentId(agentId ?? null)
@@ -175,51 +194,63 @@ export function AIAgentConfigWorkbench({
const [
specs,
defaultDefinition,
templates,
configs,
teams,
skillList,
knowledgeBaseList,
catalog,
] = await Promise.all([
fetchAIWorkflowNodeSpecs(),
fetchAIWorkflowDefaultDefinition().catch(() => fallbackDefinition),
fetchAIWorkflowTemplates(),
fetchAIConfigsAll({ modelType: AIModelType.LLM }),
fetchAgentTeamsAll(),
fetchSkillDefinitionsAll({ status: Status.Ok }),
fetchKnowledgeBasesAll({ status: Status.Ok }),
fetchMCPCatalog(),
])
setNodeSpecs(specs ?? [])
setWorkflowTemplates(templates ?? [])
setAIConfigs(configs ?? [])
setAgentTeams(teams ?? [])
setSkills(skillList ?? [])
setKnowledgeBases(knowledgeBaseList ?? [])
setToolCatalog(catalog ?? [])
if (!currentAgentId || currentAgentId <= 0) {
setAgent(null)
setWorkflowVersions([])
setAgentRevisions([])
setName("")
setDescription("")
setAIConfigId("")
setAIConfigId("")
setRuntimeMode("autonomous")
setServiceMode(String(IMConversationServiceMode.AIFirst))
setSystemPrompt("")
setWelcomeMessage("")
setReplyTimeoutSeconds("180")
setRolloutPercent("5")
setHandoffMode(String(AIAgentHandoffMode.WaitPool))
setFallbackMode(String(AIAgentFallbackMode.NoAnswer))
setFallbackMessage("")
setSelectedTeamIds([])
setSelectedSkillIds([])
setSelectedKnowledgeBaseIds([])
setDirectTools([])
replaceWorkflowDefinition(defaultDefinition ?? fallbackDefinition)
return
}
const [agentDetail, workflowDetail] = await Promise.all([
const [agentDetail, workflowDetail, revisionList] = await Promise.all([
fetchAIAgent(currentAgentId),
fetchAIAgentWorkflow(currentAgentId),
fetchAIAgentRevisions(currentAgentId),
])
setAgent(agentDetail)
setAgentRevisions(revisionList ?? [])
if (workflowDetail?.id > 0) {
const versionPage = await fetchAIWorkflowVersions({ workflowId: workflowDetail.id, limit: 20 })
setWorkflowVersions(versionPage.results ?? [])
@@ -228,16 +259,19 @@ export function AIAgentConfigWorkbench({
}
setName(agentDetail.name)
setDescription(agentDetail.description || "")
setAIConfigId(toText(agentDetail.aiConfigId))
setAIConfigId(toText(agentDetail.aiConfigId))
setRuntimeMode(agentDetail.runtimeMode === "autonomous" || agentDetail.runtimeMode === "hybrid" ? agentDetail.runtimeMode : "workflow")
setServiceMode(String(agentDetail.serviceMode || IMConversationServiceMode.AIFirst))
setSystemPrompt(agentDetail.systemPrompt || "")
setWelcomeMessage(agentDetail.welcomeMessage || "")
setReplyTimeoutSeconds(String(agentDetail.replyTimeoutSeconds ?? 180))
setRolloutPercent(String(agentDetail.rolloutPercent || 100))
setHandoffMode(String(agentDetail.handoffMode || AIAgentHandoffMode.WaitPool))
setFallbackMode(String(agentDetail.fallbackMode || AIAgentFallbackMode.NoAnswer))
setFallbackMessage(agentDetail.fallbackMessage || "")
setSelectedTeamIds((agentDetail.teams ?? []).map((team) => team.id))
setSelectedSkillIds(agentDetail.skillIds ?? [])
setSelectedKnowledgeBaseIds(agentDetail.knowledgeBaseIds ?? [])
setDirectTools(agentDetail.directTools ?? [])
replaceWorkflowDefinition(workflowDetail.draftDefinition ?? defaultDefinition ?? fallbackDefinition)
} catch (error) {
@@ -259,6 +293,14 @@ export function AIAgentConfigWorkbench({
],
[]
)
const runtimeModeOptions = useMemo(
() => [
{ value: "autonomous", label: "自主接待" },
{ value: "hybrid", label: "自主接待 + 流程" },
{ value: "workflow", label: "高级编排 / Playbooks" },
],
[]
)
const handoffModeOptions = useMemo(
() => [
{ value: String(AIAgentHandoffMode.WaitPool), label: "进入待接入池" },
@@ -271,6 +313,7 @@ export function AIAgentConfigWorkbench({
() => [
{ value: String(AIAgentFallbackMode.NoAnswer), label: "直接说明知识不足" },
{ value: String(AIAgentFallbackMode.SuggestRetry), label: "引导用户补充信息" },
{ value: String(AIAgentFallbackMode.Handoff), label: "转人工客服" },
],
[]
)
@@ -286,10 +329,18 @@ export function AIAgentConfigWorkbench({
() => skills.map((item) => ({ value: String(item.id), label: item.name })),
[skills]
)
const knowledgeBaseOptions = useMemo(
() => knowledgeBases.map((item) => ({ value: String(item.id), label: item.name })),
[knowledgeBases]
)
const directToolOptions = useMemo<DirectToolOption[]>(
() =>
toolCatalog
.filter((tool) => !tool.autoInjected && tool.sourceType === "mcp")
.filter(
(tool) =>
!tool.autoInjected &&
(tool.sourceType === "mcp" || tool.toolCode === "builtin/conversation_context" || tool.toolCode === "graph/prepare_ticket_draft")
)
.map((tool) => ({
value: tool.toolCode,
label: `${tool.title || tool.toolName} · ${tool.toolCode}`,
@@ -356,14 +407,17 @@ export function AIAgentConfigWorkbench({
name: name.trim(),
description: description.trim(),
aiConfigId: Number(aiConfigId),
runtimeMode,
serviceMode: Number(serviceMode),
systemPrompt: systemPrompt.trim(),
welcomeMessage: welcomeMessage.trim(),
replyTimeoutSeconds: Number(replyTimeoutSeconds),
rolloutPercent: Number(rolloutPercent),
teamIds: uniqueNumbers(selectedTeamIds),
handoffMode: Number(handoffMode),
fallbackMode: Number(fallbackMode),
fallbackMessage: fallbackMessage.trim(),
knowledgeBaseIds: uniqueNumbers(selectedKnowledgeBaseIds),
skillIds: uniqueNumbers(selectedSkillIds),
directTools,
}
@@ -392,6 +446,20 @@ export function AIAgentConfigWorkbench({
}
}
async function publishAutonomousAgent() {
if (!agent || runtimeMode !== "autonomous") return
setSavingAgent(true)
try {
await publishAIAgent(agent.id)
await loadData()
toast.success("Autonomous Agent published")
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to publish Autonomous Agent")
} finally {
setSavingAgent(false)
}
}
async function saveWorkflowDraft() {
if (!currentAgentId) return
setSavingWorkflow(true)
@@ -410,6 +478,36 @@ export function AIAgentConfigWorkbench({
}
}
async function rollbackAgentRevision(revisionId: number) {
if (!agent || revisionId <= 0 || revisionId === agent.publishedRevisionId) return
setSavingAgent(true)
try {
await rollbackAIAgent(agent.id, revisionId)
toast.success("已回滚到选中的 Agent 版本")
await loadData()
onAgentSaved?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "回滚 Agent 版本失败")
} finally {
setSavingAgent(false)
}
}
async function rollbackAgentRollout() {
if (!agent || agent.previousRolloutPercent < 1) return
setSavingAgent(true)
try {
await rollbackAIAgentRollout(agent.id)
toast.success("已恢复上一次灰度比例")
await loadData()
onAgentSaved?.()
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复灰度比例失败")
} finally {
setSavingAgent(false)
}
}
async function validateWorkflowDraft() {
setSavingWorkflow(true)
try {
@@ -438,6 +536,13 @@ export function AIAgentConfigWorkbench({
}
}
function applySelectedWorkflowTemplate() {
const template = workflowTemplates.find((item) => item.code === selectedWorkflowTemplate)
if (!template) return
replaceWorkflowDefinition(template.definition)
toast.success(`已应用 ${template.name} 模板,保存草稿或发布后生效`)
}
async function publishWorkflow() {
if (!currentAgentId) return
setSavingWorkflow(true)
@@ -475,13 +580,16 @@ export function AIAgentConfigWorkbench({
const sections: { key: SectionKey; title: string; icon: ReactNode }[] = [
{ key: "basic", title: "基础信息", icon: <SettingsIcon /> },
{ key: "capabilities", title: "能力来源", icon: <PlugIcon /> },
{ key: "workflow", title: "会话流程", icon: <GitBranchIcon /> },
{ key: "workflow", title: "高级编排 / Playbooks", icon: <GitBranchIcon /> },
]
const selectedTeamOptions = selectedOptions(selectedTeamIds, teamOptions)
const selectedSkillOptions = selectedOptions(selectedSkillIds, skillOptions)
const workflowPublished = isWorkflowPublished(agent)
const workflowStateText =
const autonomousPublished = runtimeMode === "autonomous" && (agent?.publishedRevisionId ?? 0) > 0
const hybridPublished = runtimeMode === "hybrid" && workflowPublished && (agent?.publishedRevisionId ?? 0) > 0
const runtimePublished = runtimeMode === "workflow" ? workflowPublished : runtimeMode === "hybrid" ? hybridPublished : autonomousPublished
const workflowStateText =
agent?.workflowStateText || (workflowPublished ? "已发布" : "未发布")
return (
@@ -494,8 +602,8 @@ export function AIAgentConfigWorkbench({
<div className="flex min-w-0 items-center gap-2">
<h1 className="truncate text-base font-semibold">{agent?.name ?? "新建 AI Agent"}</h1>
{agent?.statusName ? <Badge variant="secondary">{agent.statusName}</Badge> : null}
<Badge variant={workflowPublished ? "default" : "outline"}>
{workflowStateText}
<Badge variant={runtimePublished ? "default" : "outline"}>
{runtimeMode === "autonomous" ? (autonomousPublished ? "已发布" : "未发布") : runtimeMode === "hybrid" ? (hybridPublished ? "已发布" : "未发布") : workflowStateText}
</Badge>
{workflowPublished ? (
<Badge variant="secondary"> #{agent?.workflowVersionId}</Badge>
@@ -507,6 +615,7 @@ export function AIAgentConfigWorkbench({
null
) : (
<>
{agent && runtimeMode === "autonomous" ? <Button type="button" variant="outline" disabled={savingAgent || loading} onClick={publishAutonomousAgent}> Agent</Button> : null}
<Button
type="button"
variant="outline"
@@ -522,9 +631,9 @@ export function AIAgentConfigWorkbench({
</div>
<div className="flex min-h-0 flex-1 flex-col bg-background">
{agent && !workflowPublished ? (
{agent && !runtimePublished ? (
<div className="shrink-0 border-b border-amber-200 bg-amber-50 px-5 py-2 text-sm text-amber-900">
AI
{runtimeMode === "autonomous" ? "未发布 Agent,AI 不会自动回复。保存配置后发布 Agent,再绑定渠道或启用自动回复。" : runtimeMode === "hybrid" ? "未发布 Hybrid Agent,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本。" : "未发布 Playbook,AI 不会自动回复。保存配置后请进入“高级编排 / Playbooks”发布一个版本,再绑定渠道或启用自动回复。"}
</div>
) : null}
<div className="shrink-0 border-b bg-muted/30 px-4 py-2">
@@ -597,6 +706,9 @@ export function AIAgentConfigWorkbench({
onChange={setAIConfigId}
/>
</FieldBlock>
<FieldBlock label="运行模式">
<OptionCombobox value={runtimeMode} options={runtimeModeOptions} placeholder="选择运行模式" onChange={(value) => setRuntimeMode(value === "autonomous" || value === "hybrid" ? value : "workflow")} />
</FieldBlock>
<FieldBlock label="回复超时秒数">
<Input
type="number"
@@ -606,6 +718,17 @@ export function AIAgentConfigWorkbench({
onChange={(event) => setReplyTimeoutSeconds(event.target.value)}
/>
</FieldBlock>
<FieldBlock label="会话灰度比例(%">
<div className="flex items-center gap-2">
<Input type="number" min={1} max={100} step={1} value={rolloutPercent} onChange={(event) => setRolloutPercent(event.target.value)} />
{previousRolloutPercent > 0 ? (
<Button type="button" variant="outline" size="sm" disabled={savingAgent} onClick={rollbackAgentRollout}>
<RotateCcwIcon />
{previousRolloutPercent}%
</Button>
) : null}
</div>
</FieldBlock>
</div>
<FieldBlock label="系统提示词">
<ContentEditor
@@ -657,6 +780,23 @@ export function AIAgentConfigWorkbench({
{activeSection === "capabilities" ? (
<ConfigSection>
<div className="text-sm font-medium"></div>
<AddRow
value={knowledgeBaseToAdd}
options={knowledgeBaseOptions.filter((option) => !selectedKnowledgeBaseIds.includes(Number(option.value)))}
placeholder="选择知识库"
onValueChange={setKnowledgeBaseToAdd}
onAdd={() => {
addSelected(knowledgeBaseToAdd, selectedKnowledgeBaseIds, setSelectedKnowledgeBaseIds)
setKnowledgeBaseToAdd("")
}}
/>
<BadgeList empty="未配置知识库。" items={selectedOptions(selectedKnowledgeBaseIds, knowledgeBaseOptions)} onRemove={(id) => setSelectedKnowledgeBaseIds((current) => current.filter((item) => item !== id))} />
</ConfigSection>
) : null}
{activeSection === "capabilities" ? (
<ConfigSection>
<AddRow
value={skillToAdd}
options={skillOptions.filter((option) => !selectedSkillIds.includes(Number(option.value)))}
@@ -730,7 +870,19 @@ export function AIAgentConfigWorkbench({
) : null}
{activeSection === "workflow" ? (
<WorkflowEditor
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-2">
<OptionCombobox
value={selectedWorkflowTemplate}
options={workflowTemplates.map((item) => ({ value: item.code, label: item.name }))}
placeholder="选择 Playbook 模板"
onChange={setSelectedWorkflowTemplate}
/>
<Button type="button" variant="outline" size="sm" disabled={!selectedWorkflowTemplate || savingWorkflow || loading} onClick={applySelectedWorkflowTemplate}>
</Button>
</div>
<WorkflowEditor
key={workflowRevision}
definition={definition}
nodeSpecs={nodeSpecs}
@@ -756,7 +908,8 @@ export function AIAgentConfigWorkbench({
</Button>
}
/>
/>
</div>
) : null}
<Dialog open={versionDialogOpen} onOpenChange={setVersionDialogOpen}>
@@ -767,6 +920,9 @@ export function AIAgentConfigWorkbench({
<VersionRecordsTable
agent={agent}
workflowVersions={workflowVersions}
agentRevisions={agentRevisions}
onRollback={rollbackAgentRevision}
rollbackDisabled={savingAgent || loading}
/>
</DialogContent>
</Dialog>
@@ -828,12 +984,52 @@ function AddRow({
function VersionRecordsTable({
agent,
workflowVersions,
agentRevisions,
onRollback,
rollbackDisabled,
}: {
agent: AIAgent | null
workflowVersions: AIWorkflowVersion[]
agentRevisions: AgentRevision[]
onRollback: (revisionId: number) => void
rollbackDisabled: boolean
}) {
return (
<div className="max-h-[60vh] overflow-auto rounded-md border">
<div className="max-h-[60vh] space-y-4 overflow-auto">
<div className="rounded-md border">
<div className="border-b px-3 py-2 text-sm font-medium">Agent </div>
{agentRevisions.length > 0 ? (
<Table>
<TableHeader className="bg-muted/40">
<TableRow>
<TableHead className="w-28"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agentRevisions.map((revision) => {
const active = agent?.publishedRevisionId === revision.id
return (
<TableRow key={revision.id}>
<TableCell className="font-medium">r{revision.revision}{active ? <Badge variant="secondary" className="ml-2"></Badge> : null}</TableCell>
<TableCell className="text-muted-foreground">{revision.publishedAt || "-"}</TableCell>
<TableCell>{revision.publishedByName || "-"}</TableCell>
<TableCell>{revision.workflowVersionId > 0 ? `#${revision.workflowVersionId}` : "-"}</TableCell>
<TableCell className="text-right">
<Button type="button" variant="outline" size="sm" disabled={active || rollbackDisabled} onClick={() => onRollback(revision.id)}></Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
) : <div className="p-4 text-sm text-muted-foreground"> Agent </div>}
</div>
<div className="rounded-md border">
<div className="border-b px-3 py-2 text-sm font-medium"></div>
{workflowVersions.length > 0 ? (
<Table>
<TableHeader className="bg-muted/40">
@@ -875,6 +1071,7 @@ function VersionRecordsTable({
) : (
<div className="p-4 text-sm text-muted-foreground"></div>
)}
</div>
</div>
)
}
+2 -2
View File
@@ -122,7 +122,7 @@ export default function DashboardAIAgentsPage() {
},
{
key: "workflow",
label: "流程状态",
label: "Playbook 状态",
render: (item) => {
const published = isWorkflowPublished(item);
return (
@@ -139,7 +139,7 @@ export default function DashboardAIAgentsPage() {
</div>
{!published ? (
<div className="text-xs text-muted-foreground">
AI
PlaybookAI
</div>
) : (
<div className="text-xs text-muted-foreground">
+61 -11
View File
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Controller, Resolver, useForm, useWatch } from "react-hook-form"
import { z } from "zod/v4"
import { CopyIcon, ExternalLinkIcon } from "lucide-react"
import { CopyIcon, ExternalLinkIcon, RotateCcwIcon } from "lucide-react"
import { toast } from "sonner"
import { getWidgetDemoPath } from "@/components/support-chat/demo-navigation"
@@ -28,6 +28,7 @@ import {
fetchAIAgentsAll,
fetchChannel,
fetchWxWorkKFAccounts,
rollbackChannelAIAgentRollout,
resetChannelUserTokenSecret,
} from "@/lib/api/admin"
import { useI18n } from "@/i18n/provider"
@@ -74,6 +75,7 @@ function createSchema(t: Translate) {
.object({
channelType: z.enum(["web", "wechat_mp", "wxwork_kf"], t("channel.typeRequired")),
aiAgentId: z.string().trim().regex(/^\d+$/, t("channel.agentRequired")),
aiAgentRolloutPercent: z.coerce.number().int().min(1).max(100),
name: z.string().trim().min(1, t("channel.nameRequired")),
openKfId: z.string().trim(),
widgetTitle: z.string().trim(),
@@ -98,6 +100,7 @@ function createSchema(t: Translate) {
type EditForm = {
channelType: "web" | "wechat_mp" | "wxwork_kf"
aiAgentId: string
aiAgentRolloutPercent: number
name: string
openKfId: string
widgetTitle: string
@@ -114,6 +117,7 @@ function createEmptyForm(t: Translate): EditForm {
return {
channelType: "web",
aiAgentId: "",
aiAgentRolloutPercent: 100,
name: "",
openKfId: "",
widgetTitle: defaultWebChannelConfig.title,
@@ -202,6 +206,7 @@ function buildForm(item: AdminChannel | null, t: Translate): EditForm {
? "wechat_mp"
: "web",
aiAgentId: item.aiAgentId > 0 ? String(item.aiAgentId) : "",
aiAgentRolloutPercent: item.aiAgentRolloutPercent || 100,
name: item.name,
openKfId: parseOpenKfId(item.configJson),
widgetTitle: wechatConfig?.title ?? webConfig.title,
@@ -240,6 +245,7 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
return {
channelType,
aiAgentId: Number(form.aiAgentId),
aiAgentRolloutPercent: form.aiAgentRolloutPercent,
name: form.name.trim(),
configJson,
status,
@@ -247,8 +253,15 @@ function buildPayload(form: EditForm, status: number, t: Translate): CreateAdmin
}
}
function isAgentWorkflowPublished(agent: AIAgent | undefined) {
return Boolean(agent?.workflowPublished ?? (agent?.workflowVersionId ?? 0) > 0)
function isAgentChannelBindable(agent: AIAgent | undefined) {
if (!agent) return false
if (agent.runtimeMode === "autonomous") {
return agent.publishedRevisionId > 0
}
if (agent.runtimeMode === "hybrid") {
return agent.publishedRevisionId > 0 && agent.workflowVersionId > 0
}
return Boolean(agent.workflowPublished ?? agent.workflowVersionId > 0)
}
type ChannelFormBodyProps = Omit<ChannelFormDialogProps, "open">
@@ -300,6 +313,7 @@ function ChannelFormBody({
const [wxWorkKFAccountsLoading, setWxWorkKFAccountsLoading] = useState(false)
const [wxWorkKFAccountsError, setWxWorkKFAccountsError] = useState("")
const [channelDetail, setChannelDetail] = useState<AdminChannel | null>(null)
const [rollingBackRollout, setRollingBackRollout] = useState(false)
const [currentStatus, setCurrentStatus] = useState(0)
const form = useForm<
z.input<typeof schema>,
@@ -321,6 +335,26 @@ function ChannelFormBody({
const aiAgentId = useWatch({ control, name: "aiAgentId" })
const openKfId = useWatch({ control, name: "openKfId" })
const userTokenSecret = useWatch({ control, name: "userTokenSecret" })
const previousRolloutPercent = channelDetail?.previousAiAgentRolloutPercent ?? 0
async function rollbackRolloutPercent() {
if (!channelDetail || previousRolloutPercent < 1) return
setRollingBackRollout(true)
try {
await rollbackChannelAIAgentRollout(channelDetail.id)
setValue("aiAgentRolloutPercent", previousRolloutPercent)
setChannelDetail({
...channelDetail,
aiAgentRolloutPercent: previousRolloutPercent,
previousAiAgentRolloutPercent: channelDetail.aiAgentRolloutPercent,
})
toast.success("已恢复上一次渠道灰度比例")
} catch (error) {
toast.error(error instanceof Error ? error.message : "恢复渠道灰度比例失败")
} finally {
setRollingBackRollout(false)
}
}
useEffect(() => {
async function loadAIAgents() {
@@ -392,11 +426,11 @@ function ChannelFormBody({
const selectedAIAgent = aiAgents.find((item) => String(item.id) === aiAgentId)
const availableAIAgents = aiAgents.filter(
(item) => isAgentWorkflowPublished(item) || String(item.id) === aiAgentId
(item) => isAgentChannelBindable(item) || String(item.id) === aiAgentId
)
const aiAgentOptions = availableAIAgents.map((item) => ({
value: String(item.id),
label: isAgentWorkflowPublished(item)
label: isAgentChannelBindable(item)
? `${item.name} · 当前生效 #${item.workflowVersionId}`
: `${item.name} · 未发布`,
}))
@@ -426,8 +460,8 @@ function ChannelFormBody({
async function onFormSubmit(values: EditForm) {
const selected = aiAgents.find((item) => String(item.id) === values.aiAgentId)
if (!isAgentWorkflowPublished(selected)) {
toast.error("该 Agent 尚未发布流程,不能绑定渠道")
if (!isAgentChannelBindable(selected)) {
toast.error("该 Agent 尚未完成发布,不能绑定渠道")
return
}
await onSubmit(buildPayload(values, currentStatus, t))
@@ -521,23 +555,39 @@ function ChannelFormBody({
/>
)}
/>
{selectedAIAgent && !isAgentWorkflowPublished(selectedAIAgent) ? (
{selectedAIAgent && !isAgentChannelBindable(selectedAIAgent) ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
Agent AI Agent
</div>
) : null}
{selectedAIAgent && isAgentWorkflowPublished(selectedAIAgent) ? (
{selectedAIAgent && isAgentChannelBindable(selectedAIAgent) ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge variant="secondary">
{selectedAIAgent.workflowStateText || "已发布"}
{selectedAIAgent.runtimeMode === "autonomous" ? "已发布" : selectedAIAgent.workflowStateText || "已发布"}
</Badge>
<span> #{selectedAIAgent.workflowVersionId}</span>
<span>{selectedAIAgent.runtimeMode === "autonomous" ? `当前版本 #${selectedAIAgent.publishedRevisionId}` : `当前生效版本 #${selectedAIAgent.workflowVersionId}`}</span>
</div>
) : null}
<FieldError errors={[errors.aiAgentId]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.aiAgentRolloutPercent}>
<FieldLabel htmlFor="channel-ai-agent-rollout">AI %</FieldLabel>
<FieldContent>
<div className="flex items-center gap-2">
<Input id="channel-ai-agent-rollout" type="number" min={1} max={100} step={1} {...register("aiAgentRolloutPercent")} />
{previousRolloutPercent > 0 ? (
<Button type="button" variant="outline" size="sm" disabled={saving || rollingBackRollout} onClick={rollbackRolloutPercent}>
<RotateCcwIcon />
{previousRolloutPercent}%
</Button>
) : null}
</div>
<FieldError errors={[errors.aiAgentRolloutPercent]} />
</FieldContent>
</Field>
<Field data-invalid={!!errors.channelType}>
<FieldLabel>{t("channel.channelType")}</FieldLabel>
<FieldContent>
+230 -1
View File
@@ -177,6 +177,8 @@ export type AdminChannel = {
channelType: string
channelId: string
aiAgentId: number
aiAgentRolloutPercent: number
previousAiAgentRolloutPercent: number
aiAgentName?: string
name: string
configJson: string
@@ -194,6 +196,7 @@ export type WxWorkKFAccount = {
export type CreateAdminChannelPayload = {
channelType: string
aiAgentId: number
aiAgentRolloutPercent: number
name: string
configJson: string
status: number
@@ -216,17 +219,26 @@ export type AIAgent = {
statusName: string
aiConfigId: number
aiConfigName?: string
runtimeMode: "workflow" | "autonomous" | "hybrid"
runtimeModeName: string
maxSteps: number
contextWindow: number
toolPolicy: string
knowledgePolicy: string
serviceMode: number
serviceModeName: string
systemPrompt: string
welcomeMessage: string
replyTimeoutSeconds: number
rolloutPercent: number
previousRolloutPercent: number
teams: { id: number; name: string }[]
handoffMode: number
handoffModeName: string
fallbackMode: number
fallbackModeName: string
fallbackMessage: string
knowledgeBaseIds: number[]
skillIds: number[]
skills: { id: number; name: string }[]
directTools: {
@@ -238,6 +250,7 @@ export type AIAgent = {
arguments?: Record<string, string>
}[]
workflowVersionId: number
publishedRevisionId: number
workflowPublished: boolean
workflowState: string
workflowStateText: string
@@ -252,14 +265,21 @@ export type CreateAIAgentPayload = {
name: string
description: string
aiConfigId: number
runtimeMode?: "workflow" | "autonomous" | "hybrid"
maxSteps?: number
contextWindow?: number
toolPolicy?: string
knowledgePolicy?: string
serviceMode: number
systemPrompt: string
welcomeMessage: string
replyTimeoutSeconds: number
rolloutPercent: number
teamIds: number[]
handoffMode: number
fallbackMode: number
fallbackMessage: string
knowledgeBaseIds: number[]
skillIds: number[]
directTools: {
toolCode: string
@@ -275,6 +295,18 @@ export type UpdateAIAgentPayload = CreateAIAgentPayload & {
id: number
}
export type AgentRevision = {
id: number
agentId: number
revision: number
workflowVersionId: number
status: number
definitionHash: string
publishedAt: string
publishedById: number
publishedByName: string
}
export type AIWorkflowPosition = {
x: number
y: number
@@ -387,6 +419,13 @@ export type AIWorkflowNodeSpec = {
defaultInputs?: Record<string, AIWorkflowValue>
}
export type AIWorkflowTemplate = {
code: string
name: string
description: string
definition: AIWorkflowDefinition
}
export type AIWorkflowValidationResult = {
valid: boolean
errors: {
@@ -568,6 +607,123 @@ export type AIWorkflowRun = {
nodes?: AIWorkflowNodeRun[]
}
export type AgentRun = {
id: number
conversationId: number
aiAgentId: number
agentRevisionId: number
sourceMessageId: number
workflowRunId: number
engineCode: string
status: string
promptTokens: number
completionTokens: number
startedAt: string
endedAt: string
durationMs: number
errorMessage: string
traceData: string
createdAt: string
updatedAt: string
steps?: AgentStep[]
toolCalls?: AgentToolCall[]
qualityFeedback?: AgentRunQualityFeedback
}
export type AgentRunQualityFeedback = {
id: number
agentRunId: number
resolutionStatus: "unknown" | "resolved" | "unresolved"
evidenceStatus: "unknown" | "supported" | "unsupported"
comment: string
updateUserName: string
updatedAt: string
}
export type AgentRunMetrics = {
totalRuns: number
completedRuns: number
failedRuns: number
interruptedRuns: number
completionRate: number
toolCalls: number
toolSuccessRate: number
averageSteps: number
averageDurationMs: number
p95DurationMs: number
promptTokens: number
completionTokens: number
handoffRate: number
knowledgeFallbackRate: number
resumedInterrupts: number
resolvedInterrupts: number
interruptRecoveryRate: number
reviewedRuns: number
resolvedRuns: number
resolutionRate: number
unsupportedEvidenceRuns: number
unsupportedEvidenceRate: number
}
export type AgentRunEngineComparison = {
engineCode: string
metrics: AgentRunMetrics
}
export type AgentEvaluationCase = {
id: string
category?: string
message: string
history?: string[]
expect?: Record<string, unknown>
}
export type AgentEvaluationReport = {
engineCode: string
total: number
passed: number
results: {
caseId: string
category: string
engineCode: string
passed: boolean
replyText: string
interrupted: boolean
error?: string
finding?: string
}[]
csv: string
}
export type AgentStep = {
id: number
agentRunId: number
stepType: string
stepCode: string
status: string
inputPreview: string
outputPreview: string
errorMessage: string
startedAt: string
endedAt: string
durationMs: number
}
export type AgentToolCall = {
id: number
agentRunId: number
agentStepId: number
toolCode: string
riskLevel: string
requireConfirm: boolean
status: string
argumentsPreview: string
resultPreview: string
errorMessage: string
durationMs: number
createdAt: string
}
export type AdminAgentProfile = {
id: number
userId: number
@@ -744,6 +900,13 @@ export function updateChannel(payload: UpdateAdminChannelPayload) {
})
}
export function rollbackChannelAIAgentRollout(id: number) {
return request<void>("/api/dashboard/channel/rollback_ai_agent_rollout", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function updateChannelStatus(id: number, status: number) {
return request<void>("/api/dashboard/channel/update_status", {
method: "POST",
@@ -800,6 +963,31 @@ export function updateAIAgent(payload: UpdateAIAgentPayload) {
})
}
export function publishAIAgent(id: number) {
return request<void>("/api/dashboard/ai-agent/publish", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function fetchAIAgentRevisions(id: number) {
return request<AgentRevision[]>(`/api/dashboard/ai-agent/${id}/revision/list`)
}
export function rollbackAIAgent(id: number, revisionId: number) {
return request<void>("/api/dashboard/ai-agent/rollback", {
method: "POST",
body: JSON.stringify({ id, revisionId }),
})
}
export function rollbackAIAgentRollout(id: number) {
return request<void>("/api/dashboard/ai-agent/rollback_rollout", {
method: "POST",
body: JSON.stringify({ id }),
})
}
export function deleteAIAgent(id: number) {
return request<void>("/api/dashboard/ai-agent/delete", {
method: "POST",
@@ -837,7 +1025,11 @@ export function fetchAIWorkflowNodeSpecs() {
}
export function fetchAIWorkflowDefaultDefinition() {
return request<AIWorkflowDefinition>("/api/dashboard/ai-workflow/default-definition")
return request<AIWorkflowDefinition>("/api/dashboard/ai-workflow/default-definition")
}
export function fetchAIWorkflowTemplates() {
return request<AIWorkflowTemplate[]>("/api/dashboard/ai-workflow/template/list")
}
export function fetchAIWorkflowVersions(query?: Record<string, string | number | undefined>) {
@@ -1135,6 +1327,43 @@ export function fetchAIWorkflowRun(id: number) {
return request<AIWorkflowRun>(`/api/dashboard/ai-workflow/run/${id}`)
}
export function fetchAgentRuns(query?: Record<string, string | number | undefined>) {
return request<PageResult<AgentRun>>(
`/api/dashboard/agent-run/list${toQueryString(query)}`
)
}
export function fetchAgentRun(id: number) {
return request<AgentRun>(`/api/dashboard/agent-run/${id}`)
}
export function fetchAgentRunMetrics(aiAgentId?: number) {
return request<AgentRunMetrics>(`/api/dashboard/agent-run/metrics${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
}
export function fetchAgentRunEngineComparisons(aiAgentId?: number) {
return request<AgentRunEngineComparison[]>(`/api/dashboard/agent-run/comparison${toQueryString(aiAgentId ? { aiAgentId } : undefined)}`)
}
export function runAgentEvaluation(payload: { aiAgentId: number; engineCode: string; cases: AgentEvaluationCase[] }) {
return request<AgentEvaluationReport>("/api/dashboard/agent-run/evaluate", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function saveAgentRunQualityFeedback(payload: {
agentRunId: number
resolutionStatus: AgentRunQualityFeedback["resolutionStatus"]
evidenceStatus: AgentRunQualityFeedback["evidenceStatus"]
comment: string
}) {
return request<void>("/api/dashboard/agent-run/quality_feedback", {
method: "POST",
body: JSON.stringify(payload),
})
}
export function updateSkillDefinitionStatus(id: number, status: number) {
return request<void>("/api/dashboard/skill-definition/update_status", {
method: "POST",
+13
View File
@@ -3,10 +3,12 @@
export enum AIAgentFallbackMode {
NoAnswer = 1,
SuggestRetry = 2,
Handoff = 3,
}
export const AIAgentFallbackModeLabels: Record<AIAgentFallbackMode, string> = {
[AIAgentFallbackMode.NoAnswer]: "直接说明知识不足",
[AIAgentFallbackMode.SuggestRetry]: "引导用户补充信息",
[AIAgentFallbackMode.Handoff]: "转人工客服",
}
export enum AIAgentHandoffMode {
@@ -20,6 +22,17 @@ export const AIAgentHandoffModeLabels: Record<AIAgentHandoffMode, string> = {
[AIAgentHandoffMode.AIHoldAndNotify]: "AI继续接待并提醒人工",
}
export enum AIAgentRuntimeMode {
Workflow = "workflow",
Autonomous = "autonomous",
Hybrid = "hybrid",
}
export const AIAgentRuntimeModeLabels: Record<AIAgentRuntimeMode, string> = {
[AIAgentRuntimeMode.Workflow]: "流程编排",
[AIAgentRuntimeMode.Autonomous]: "自主运行",
[AIAgentRuntimeMode.Hybrid]: "混合运行",
}
export enum AIModelType {
LLM = "llm",
Embedding = "embedding",
+6
View File
@@ -211,6 +211,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
icon: <WorkflowIcon />,
requiredPermission: "aiAgent.view",
},
{
titleKey: "nav.agentRuns",
url: "/dashboard/agent-runs",
icon: <BotMessageSquareIcon />,
requiredPermission: "aiAgent.view",
},
],
},
{
+32
View File
@@ -2288,6 +2288,37 @@
"input": "Input",
"output": "Output"
},
"agentRun": {
"conversation": "Conversation",
"agent": "Agent",
"engine": "Engine",
"status": "Status",
"startedAt": "Started",
"duration": "Duration",
"tokens": "Input/Output Tokens",
"error": "Error",
"refresh": "Refresh",
"query": "Query",
"loading": "Loading agent runs",
"empty": "No agent runs",
"loadFailed": "Failed to load agent runs",
"loadDetailFailed": "Failed to load agent run detail",
"detailTitle": "Agent Run Detail",
"detailDescription": "View the unified run audit",
"loadingDetail": "Loading agent run detail",
"close": "Close",
"revision": "Revision",
"trace": "Trace",
"steps": "Steps",
"emptySteps": "No steps recorded",
"toolCalls": "Tool Calls",
"emptyToolCalls": "No tool calls recorded",
"input": "Input Preview",
"output": "Output Preview",
"arguments": "Arguments Preview",
"result": "Result Preview",
"notFound": "Agent run not found"
},
"nav": {
"overview": "Overview",
"receptionCenter": "Support Desk",
@@ -2308,6 +2339,7 @@
"aiAgents": "Agents",
"aiWorkflows": "AI Workflows",
"workflowRuns": "Workflow Audit",
"agentRuns": "Agent Audit",
"skillDefinition": "Skills",
"mcp": "MCP tools",
"system": "System",

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