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
+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{},