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:
@@ -49,11 +49,12 @@ func DebugRunSkill(ctx context.Context, req request.SkillDebugRunRequest) (*resp
|
||||
MessageType: enums.IMMessageTypeText,
|
||||
Content: strings.TrimSpace(req.UserMessage),
|
||||
}
|
||||
summary, err := Service.Run(ctx, applicationruntime.Request{
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.RunPrepared(ctx, applicationruntime.Request{
|
||||
Conversation: *conversation,
|
||||
UserMessage: message,
|
||||
AIAgent: debugAgent,
|
||||
AIConfig: *aiConfig,
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
return buildSkillDebugRunResponse(req, summary, skill), err
|
||||
@@ -92,7 +93,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
|
||||
return nil, errorsx.InvalidParamI18n("error.e0117")
|
||||
}
|
||||
resumeText := strings.TrimSpace(req.UserMessage)
|
||||
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.ResumePrepared(ctx, applicationruntime.ResumeRequest{
|
||||
Conversation: *conversation,
|
||||
AIAgent: *aiAgent,
|
||||
AIConfig: *aiConfig,
|
||||
@@ -100,6 +101,7 @@ func DebugResumeSkill(ctx context.Context, req request.SkillDebugResumeRequest)
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(pendingInterrupt.InterruptID): resumeText,
|
||||
},
|
||||
Debug: true,
|
||||
})
|
||||
if err != nil {
|
||||
if isCheckpointMissingError(err) {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/dto/response"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/errorsx"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
func init() {
|
||||
svc.AgentEvaluationRunHook = RunAgentEvaluation
|
||||
}
|
||||
|
||||
func RunAgentEvaluation(ctx context.Context, req request.RunAgentEvaluationRequest) (*response.AgentEvaluationReportResponse, error) {
|
||||
agent := svc.AIAgentService.Get(req.AIAgentID)
|
||||
if agent == nil || agent.Status != enums.StatusOk {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0007")
|
||||
}
|
||||
config := svc.AIConfigService.Get(agent.AIConfigID)
|
||||
if config == nil {
|
||||
return nil, errorsx.InvalidParamI18n("error.e0008")
|
||||
}
|
||||
cases := make([]applicationruntime.OfflineEvaluationCase, 0, len(req.Cases))
|
||||
for _, item := range req.Cases {
|
||||
cases = append(cases, applicationruntime.OfflineEvaluationCase{ID: item.ID, Category: item.Category, Message: item.Message, History: item.History, Expect: item.Expect})
|
||||
}
|
||||
report, err := applicationruntime.NewService().RunOfflineEvaluation(ctx, req.EngineCode, *agent, *config, cases)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
csv, err := report.CSV()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ret := &response.AgentEvaluationReportResponse{EngineCode: report.EngineCode, Total: report.Total, Passed: report.Passed, CSV: csv, Results: make([]response.AgentEvaluationResultResponse, 0, len(report.Results))}
|
||||
for _, item := range report.Results {
|
||||
ret.Results = append(ret.Results, response.AgentEvaluationResultResponse{CaseID: item.CaseID, Category: item.Category, EngineCode: item.EngineCode, Passed: item.Passed, ReplyText: item.ReplyText, Interrupted: item.Interrupted, Error: item.Error, Finding: item.Finding})
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
// Package readtools executes deterministic, read-only graph tools through the
|
||||
// shared Tool Registry boundary.
|
||||
package readtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func ExecuteGraphTool(ctx context.Context, conversation models.Conversation, toolCode string, arguments map[string]any, policy aitooling.Policy) (aitooling.Definition, string, error) {
|
||||
toolCode = toolx.NormalizeToolCodeAlias(strings.TrimSpace(toolCode))
|
||||
if toolCode != toolx.GraphTriageServiceRequest.Code && toolCode != toolx.GraphAnalyzeConversation.Code && toolCode != toolx.GraphPrepareTicketDraft.Code {
|
||||
return aitooling.Definition{}, "", fmt.Errorf("tool is not a graph read tool")
|
||||
}
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolCode)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, "", err
|
||||
}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition,
|
||||
Arguments: arguments,
|
||||
Policy: policy,
|
||||
}); err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
data, err := json.Marshal(arguments)
|
||||
if err != nil {
|
||||
return definition, "", err
|
||||
}
|
||||
switch toolCode {
|
||||
case toolx.GraphTriageServiceRequest.Code:
|
||||
result, err := graphs.NewTriageServiceRequestGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
case toolx.GraphAnalyzeConversation.Code:
|
||||
result, err := graphs.NewAnalyzeConversationGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
default:
|
||||
result, err := graphs.NewPrepareTicketDraftGraph(conversation).Run(ctx, string(data))
|
||||
return definition, result, err
|
||||
}
|
||||
}
|
||||
|
||||
// RetrieveKnowledge executes the built-in knowledge tool after the same
|
||||
// registry policy and timeout checks used by graph tools.
|
||||
func RetrieveKnowledge(ctx context.Context, agent models.AIAgent, knowledgeBaseIDs []int64, query string, policy aitooling.Policy) (aitooling.Definition, *retrievers.KnowledgeRetrieveResult, error) {
|
||||
definition, err := aitooling.DefaultRegistry.Resolve(toolx.BuiltinKnowledgeRetrieve.Code)
|
||||
if err != nil {
|
||||
return aitooling.Definition{}, nil, err
|
||||
}
|
||||
arguments := map[string]any{"query": strings.TrimSpace(query), "knowledgeBaseIds": knowledgeBaseIDs}
|
||||
if err := aitooling.DefaultPolicyGuard.Authorize(aitooling.Invocation{
|
||||
Definition: definition,
|
||||
Arguments: arguments,
|
||||
Policy: policy,
|
||||
}); err != nil {
|
||||
return definition, nil, err
|
||||
}
|
||||
if definition.TimeoutMS > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, time.Duration(definition.TimeoutMS)*time.Millisecond)
|
||||
defer cancel()
|
||||
}
|
||||
result, err := retrievers.NewKnowledgeRetriever(agent, knowledgeBaseIDs).RetrieveContext(ctx, strings.TrimSpace(query))
|
||||
return definition, result, err
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package readtools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
)
|
||||
|
||||
func TestExecuteGraphToolRejectsDisallowedToolBeforeGraphExecution(t *testing.T) {
|
||||
definition, _, err := ExecuteGraphTool(context.Background(), models.Conversation{}, toolx.GraphAnalyzeConversation.Code, map[string]any{
|
||||
"observedIssue": "需要分析的问题",
|
||||
}, aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolx.GraphPrepareTicketDraft.Code},
|
||||
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
|
||||
Confirmed: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected policy guard to reject the graph tool")
|
||||
}
|
||||
if definition.Code != toolx.GraphAnalyzeConversation.Code {
|
||||
t.Fatalf("definition code = %q, want %q", definition.Code, toolx.GraphAnalyzeConversation.Code)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package runtime
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"time"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
@@ -31,9 +33,9 @@ func newReplyCommitService() *replyCommitService {
|
||||
}
|
||||
|
||||
func (s *replyCommitService) SendAIReply(input replyCommitInput) (*models.Message, error) {
|
||||
replyText := strings.TrimSpace(input.ReplyText)
|
||||
if replyText == "" {
|
||||
return nil, nil
|
||||
replyText, err := aitooling.NormalizeCustomerReply(input.ReplyText)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
replyMessage, err := svc.MessageService.SendAIMessageWithRequestIDAndWorkflowRunID(
|
||||
input.Conversation.ID,
|
||||
|
||||
@@ -46,6 +46,26 @@ func TestReplyCommitStoresWorkflowRunIDOnAIMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplyCommitRejectsSensitiveModelOutput(t *testing.T) {
|
||||
db := setupReplyCommitTestDB(t)
|
||||
aiAgent := createReplyCommitTestAIAgent(t, db)
|
||||
conversation := createReplyCommitTestConversation(t, db, aiAgent.ID)
|
||||
_, err := newReplyCommitService().CommitAIReply(replyCommitInput{
|
||||
Conversation: *conversation, Message: models.Message{ID: 102, RequestID: "trace-102"}, AIAgent: *aiAgent,
|
||||
ReplyText: "authorization=Bearer-secret", ClientPrefix: "ai_reply",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected sensitive model output to be rejected")
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&models.Message{}).Where("conversation_id = ?", conversation.ID).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count messages: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("unexpected message written for rejected output: %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func setupReplyCommitTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbName := "reply_commit_test_" + strings.NewReplacer("/", "_").Replace(t.Name())
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
|
||||
@@ -13,6 +17,36 @@ func newReplyEligibility() *replyEligibility {
|
||||
return &replyEligibility{}
|
||||
}
|
||||
|
||||
// IsAIAgentRolloutEligible uses a stable conversation bucket so one customer
|
||||
// remains consistently inside or outside a gray release throughout a session.
|
||||
// Missing legacy values are treated as 100 to preserve existing behavior.
|
||||
func IsAIAgentRolloutEligible(conversation models.Conversation, aiAgent models.AIAgent, channel *models.Channel) bool {
|
||||
percent := normalizedRolloutPercent(aiAgent.RolloutPercent)
|
||||
if channel != nil {
|
||||
channelPercent := normalizedRolloutPercent(channel.AIAgentRolloutPercent)
|
||||
if channelPercent < percent {
|
||||
percent = channelPercent
|
||||
}
|
||||
}
|
||||
if percent >= 100 {
|
||||
return true
|
||||
}
|
||||
if conversation.ID <= 0 {
|
||||
return false
|
||||
}
|
||||
seed := fmt.Sprintf("channel=%d;conversation=%d;agent=%d", conversation.ChannelID, conversation.ID, aiAgent.ID)
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
bucket := int(binary.BigEndian.Uint64(sum[:8]) % 100)
|
||||
return bucket < percent
|
||||
}
|
||||
|
||||
func normalizedRolloutPercent(percent int) int {
|
||||
if percent <= 0 || percent > 100 {
|
||||
return 100
|
||||
}
|
||||
return percent
|
||||
}
|
||||
|
||||
func (e *replyEligibility) CanReply(conversation models.Conversation, message models.Message, aiAgent models.AIAgent) bool {
|
||||
if message.SenderType != enums.IMSenderTypeCustomer {
|
||||
return false
|
||||
|
||||
@@ -48,6 +48,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
CheckPointData: `{"confirmNodeId":"confirm_1"}`,
|
||||
Interrupted: true,
|
||||
WorkflowRunID: 99,
|
||||
AgentRunID: 88,
|
||||
Interrupts: []applicationruntime.InterruptContextSummary{
|
||||
{Type: "human_confirm", ID: "confirm_1", InfoPreview: `{"message":"请确认"}`},
|
||||
},
|
||||
@@ -58,7 +59,7 @@ func TestBuildConversationInterruptStoresWorkflowCheckpointData(t *testing.T) {
|
||||
if item.RequestData != `{"confirmNodeId":"confirm_1"}` {
|
||||
t.Fatalf("unexpected request data: %q", item.RequestData)
|
||||
}
|
||||
if item.WorkflowRunID != 99 || item.WorkflowNodeID != "confirm_1" {
|
||||
if item.WorkflowRunID != 99 || item.AgentRunID != 88 || item.WorkflowNodeID != "confirm_1" {
|
||||
t.Fatalf("unexpected workflow interrupt identity: run=%d node=%q", item.WorkflowRunID, item.WorkflowNodeID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ func buildConversationInterrupt(conversation models.Conversation, message models
|
||||
}
|
||||
item.ConversationID = conversation.ID
|
||||
item.AIAgentID = aiAgent.ID
|
||||
item.AgentRunID = summary.AgentRunID
|
||||
item.SourceMessageID = message.ID
|
||||
item.InterruptID = firstInterruptID(summary)
|
||||
item.InterruptType = firstInterruptType(summary)
|
||||
|
||||
@@ -82,6 +82,9 @@ func (s *replyInterruptService) ResumePendingInterrupt(ctx context.Context, owne
|
||||
|
||||
func (s *replyInterruptService) HandleInterruptedSummary(owner *aiReplyService, replyCtx aiReplyContext, summary *applicationruntime.Summary) error {
|
||||
pending := buildConversationInterrupt(replyCtx.Conversation, replyCtx.Message, replyCtx.AIAgent, summary)
|
||||
if pending != nil && pending.AgentRunID > 0 {
|
||||
pending.AgentStepID = svc.AgentRunService.GetLatestStepID(pending.AgentRunID)
|
||||
}
|
||||
if err := svc.ConversationInterruptService.CreateOrUpdatePending(pending); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -49,6 +49,23 @@ func TestReplyEligibilityCanReply(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIAgentRolloutUsesStableConversationBucket(t *testing.T) {
|
||||
conversation := models.Conversation{ID: 101, ChannelID: 7}
|
||||
agent := models.AIAgent{ID: 9, RolloutPercent: 50}
|
||||
first := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100})
|
||||
for range 20 {
|
||||
if got := IsAIAgentRolloutEligible(conversation, agent, &models.Channel{AIAgentRolloutPercent: 100}); got != first {
|
||||
t.Fatalf("rollout bucket changed within one conversation: first=%t got=%t", first, got)
|
||||
}
|
||||
}
|
||||
if normalizedRolloutPercent(0) != 100 || normalizedRolloutPercent(101) != 100 || normalizedRolloutPercent(25) != 25 {
|
||||
t.Fatal("unexpected rollout percent normalization")
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, models.AIAgent{ID: 9, RolloutPercent: 0}, &models.Channel{}) {
|
||||
t.Fatal("legacy zero rollout values must preserve full rollout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveReplyTimeout(t *testing.T) {
|
||||
service := newAIReplyService()
|
||||
aiAgent := newAIAgentFixture()
|
||||
|
||||
@@ -58,6 +58,9 @@ func (s *aiReplyService) TriggerReply(ctx context.Context, conversation models.C
|
||||
if s.eligibility != nil && !s.eligibility.CanReply(conversation, message, aiAgent) {
|
||||
return nil
|
||||
}
|
||||
if !IsAIAgentRolloutEligible(conversation, aiAgent, svc.ChannelService.Get(conversation.ChannelID)) {
|
||||
return nil
|
||||
}
|
||||
if pendingInterrupt := svc.ConversationInterruptService.FindLatestPendingByConversationID(conversation.ID); pendingInterrupt != nil {
|
||||
replyCtx.PendingInterrupt = pendingInterrupt
|
||||
return s.resumePendingInterrupt(ctx, replyCtx)
|
||||
@@ -82,6 +85,16 @@ func (s *aiReplyService) executeReply(ctx context.Context, replyCtx aiReplyConte
|
||||
if summary != nil && summary.Interrupted {
|
||||
return s.interrupts.HandleInterruptedSummary(s, replyCtx, summary)
|
||||
}
|
||||
if summary != nil && summary.HandoffRequested {
|
||||
if _, err := svc.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
replyCtx.Conversation.ID,
|
||||
replyCtx.AIAgent,
|
||||
"knowledge evidence unavailable",
|
||||
replyCtx.Message.RequestID,
|
||||
); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if summary != nil && strings.TrimSpace(summary.ReplyText) != "" {
|
||||
_, err := s.commit.CommitAIReply(replyCommitInput{
|
||||
Conversation: replyCtx.Conversation,
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
applicationruntime "agent-desk/internal/ai/application/runtime"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/models"
|
||||
svc "agent-desk/internal/services"
|
||||
)
|
||||
|
||||
type runtimeReplyExecutor struct{}
|
||||
@@ -31,15 +30,10 @@ func newRuntimeReplyExecutor() *runtimeReplyExecutor {
|
||||
}
|
||||
|
||||
func (e *runtimeReplyExecutor) Run(ctx context.Context, input runtimeReplyRunInput) (*applicationruntime.Summary, error) {
|
||||
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, fmt.Errorf("ai config is nil")
|
||||
}
|
||||
summary, err := Service.Run(ctx, applicationruntime.Request{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *aiConfig,
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Run(ctx, applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
})
|
||||
return summary, err
|
||||
}
|
||||
@@ -48,15 +42,12 @@ func (e *runtimeReplyExecutor) ResumePendingInterrupt(ctx context.Context, input
|
||||
if input.PendingInterrupt == nil {
|
||||
return nil, fmt.Errorf("pending interrupt is required")
|
||||
}
|
||||
aiConfig := svc.AIConfigService.Get(input.AIAgent.AIConfigID)
|
||||
if aiConfig == nil {
|
||||
return nil, fmt.Errorf("ai config is nil")
|
||||
}
|
||||
summary, err := Service.Resume(ctx, applicationruntime.ResumeRequest{
|
||||
Conversation: input.Conversation,
|
||||
UserMessage: input.Message,
|
||||
AIAgent: input.AIAgent,
|
||||
AIConfig: *aiConfig,
|
||||
summary, err := applicationruntime.DefaultAgentApplicationService.Resume(ctx, applicationruntime.ApplicationResumeInput{
|
||||
ApplicationRunInput: applicationruntime.ApplicationRunInput{
|
||||
ConversationID: input.Conversation.ID,
|
||||
MessageID: input.Message.ID,
|
||||
AIAgentID: input.AIAgent.ID,
|
||||
},
|
||||
CheckPointID: strings.TrimSpace(input.PendingInterrupt.CheckPointID),
|
||||
ResumeData: map[string]string{
|
||||
strings.TrimSpace(input.PendingInterrupt.InterruptID): strings.TrimSpace(input.Message.Content),
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"agent-desk/internal/ai/mcps"
|
||||
"agent-desk/internal/ai/runtime/registry"
|
||||
"agent-desk/internal/ai/runtime/tooling"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/pkg/i18nx"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
|
||||
@@ -167,11 +168,17 @@ func (t *ToolSearchTool) invokeTargetTool(ctx context.Context, toolCode string,
|
||||
if !containsToolCode(t.allowedToolCodes, toolCode) {
|
||||
return "", i18nx.Errorf("error.e0279")
|
||||
}
|
||||
result, err := mcps.Runtime.CallTool(ctx, serverCode, toolName, cloneArguments(arguments))
|
||||
// A workflow administrator's allow-list is the explicit approval boundary
|
||||
// for MCP tools. The registry still enforces its call limit and normalizes
|
||||
// the safety metadata used by future autonomous engines.
|
||||
_, result, err := aitooling.DefaultMCPExecutor.Execute(ctx, toolCode, arguments, aitooling.Policy{
|
||||
AllowedToolCodes: t.allowedToolCodes,
|
||||
Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buildToolCallResultSummary(result), nil
|
||||
return aitooling.SanitizePreview(buildToolCallResultSummary(result)), nil
|
||||
}
|
||||
|
||||
func (t *ToolSearchTool) loadAllowedCandidates(ctx context.Context) ([]toolSearchCandidate, error) {
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
|
||||
"agent-desk/internal/ai"
|
||||
"agent-desk/internal/ai/runtime/graphs"
|
||||
"agent-desk/internal/ai/runtime/retrievers"
|
||||
"agent-desk/internal/ai/runtime/readtools"
|
||||
aitooling "agent-desk/internal/ai/tooling"
|
||||
"agent-desk/internal/ai/workflow/dsl"
|
||||
workflowregistry "agent-desk/internal/ai/workflow/registry"
|
||||
"agent-desk/internal/models"
|
||||
"agent-desk/internal/pkg/dto"
|
||||
"agent-desk/internal/pkg/dto/request"
|
||||
"agent-desk/internal/pkg/enums"
|
||||
"agent-desk/internal/pkg/toolx"
|
||||
"agent-desk/internal/services"
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ type Input struct {
|
||||
UserMessage models.Message
|
||||
AIAgent models.AIAgent
|
||||
AIConfig models.AIConfig
|
||||
Debug bool
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
@@ -355,21 +356,37 @@ func (e *Executor) executeCreateTicket(state *runState, node dsl.Node) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if state.input.Debug {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"ticketId": int64(0), "ticketNo": "", "created": false,
|
||||
"message": "调试运行不会创建工单。", "skipped": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
draft := asMap(state.resolveInput(node, "ticketDraft"))
|
||||
title := strings.TrimSpace(toString(draft["title"]))
|
||||
description := strings.TrimSpace(toString(draft["description"]))
|
||||
item, err := services.TicketService.CreateFromConversation(request.CreateTicketFromConversationRequest{
|
||||
ConversationID: state.input.Conversation.ID,
|
||||
Title: title,
|
||||
Description: description,
|
||||
}, workflowAIPrincipal(state.input.AIAgent))
|
||||
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
||||
ToolCode: toolx.GraphCreateTicketConfirm.Code, Arguments: map[string]any{"title": title, "description": description},
|
||||
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var output struct {
|
||||
TicketID int64 `json:"ticketId"`
|
||||
TicketNo string `json:"ticketNo"`
|
||||
Created bool `json:"created"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.ResultData), &output); err != nil {
|
||||
return err
|
||||
}
|
||||
item := &models.Ticket{ID: output.TicketID, TicketNo: output.TicketNo}
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"ticketId": item.ID,
|
||||
"ticketNo": item.TicketNo,
|
||||
"created": true,
|
||||
"created": output.Created,
|
||||
"message": buildTicketCreatedMessage(item),
|
||||
})
|
||||
return nil
|
||||
@@ -386,18 +403,6 @@ func buildTicketCreatedMessage(item *models.Ticket) string {
|
||||
return "工单已创建,工单号:" + ticketNo + "。"
|
||||
}
|
||||
|
||||
func workflowAIPrincipal(aiAgent models.AIAgent) *dto.AuthPrincipal {
|
||||
username := strings.TrimSpace(aiAgent.Name)
|
||||
if username == "" {
|
||||
username = "AI"
|
||||
}
|
||||
return &dto.AuthPrincipal{
|
||||
UserID: 0,
|
||||
Username: username,
|
||||
Nickname: username,
|
||||
}
|
||||
}
|
||||
|
||||
type workflowConversationUnderstanding struct {
|
||||
NormalizedMessage string
|
||||
MessageIntent string
|
||||
@@ -610,11 +615,14 @@ func (e *Executor) executePrepareTicketDraft(ctx context.Context, state *runStat
|
||||
if currentAttempt := strings.TrimSpace(readStringConfig(node.Data.Config, "currentAttempt")); currentAttempt != "" {
|
||||
input.CurrentAttempt = currentAttempt
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := graphs.NewPrepareTicketDraftGraph(state.input.Conversation).Run(ctx, string(args))
|
||||
_, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphPrepareTicketDraft.Code, map[string]any{
|
||||
"title": input.Title,
|
||||
"description": input.Description,
|
||||
"issue": input.Issue,
|
||||
"impact": input.Impact,
|
||||
"expectedOutcome": input.ExpectedOutcome,
|
||||
"currentAttempt": input.CurrentAttempt,
|
||||
}, workflowReadToolPolicy(toolx.GraphPrepareTicketDraft.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -665,11 +673,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
if strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext")) != "" {
|
||||
input.AdditionalContext = strings.TrimSpace(readStringConfig(node.Data.Config, "additionalContext"))
|
||||
}
|
||||
args, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := graphs.NewAnalyzeConversationGraph(state.input.Conversation).Run(ctx, string(args))
|
||||
_, raw, err := readtools.ExecuteGraphTool(ctx, state.input.Conversation, toolx.GraphAnalyzeConversation.Code, map[string]any{
|
||||
"goal": input.Goal,
|
||||
"observedIssue": input.ObservedIssue,
|
||||
"needTicket": input.NeedTicket,
|
||||
"needHumanHandoff": input.NeedHumanHandoff,
|
||||
"needQualityCheck": input.NeedQualityCheck,
|
||||
"additionalContext": input.AdditionalContext,
|
||||
}, workflowReadToolPolicy(toolx.GraphAnalyzeConversation.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -687,6 +698,14 @@ func (e *Executor) executeAnalyzeConversation(ctx context.Context, state *runSta
|
||||
return nil
|
||||
}
|
||||
|
||||
func workflowReadToolPolicy(toolCode string) aitooling.Policy {
|
||||
return aitooling.Policy{
|
||||
AllowedToolCodes: []string{toolCode},
|
||||
AllowedRiskLevels: []string{aitooling.RiskLevelRead},
|
||||
Confirmed: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
if _, hasConfirmedInput := node.Data.InputsValues["confirmed"]; hasConfirmedInput && !truthy(state.resolveInput(node, "confirmed")) {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
@@ -700,16 +719,32 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
})
|
||||
return nil
|
||||
}
|
||||
if state.input.Debug {
|
||||
state.setNodeVars(node.ID, map[string]any{
|
||||
"handoffId": int64(0), "reason": strings.TrimSpace(toString(state.resolveInput(node, "reason"))),
|
||||
"decision": "cancelled", "teamId": int64(0), "assigneeId": int64(0),
|
||||
"message": "调试运行不会转人工。", "skipped": true,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
reason := strings.TrimSpace(toString(state.resolveInput(node, "reason")))
|
||||
result, err := services.ConversationHumanDispatchService.HandoffByAIWithRequestID(
|
||||
state.input.Conversation.ID,
|
||||
state.input.AIAgent,
|
||||
reason,
|
||||
strings.TrimSpace(state.input.UserMessage.RequestID),
|
||||
)
|
||||
result, err := services.BusinessToolExecutor.Execute(context.Background(), services.BusinessToolInput{
|
||||
Conversation: state.input.Conversation, AIAgent: state.input.AIAgent,
|
||||
ToolCode: toolx.GraphHandoffConversation.Code, Arguments: map[string]any{"reason": reason},
|
||||
IdempotencyKey: workflowToolIdempotencyKey(state, node), Confirmed: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var handoff struct {
|
||||
Decision string `json:"decision"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
AssigneeID int64 `json:"assigneeId"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.ResultData), &handoff); err != nil {
|
||||
return err
|
||||
}
|
||||
output := map[string]any{
|
||||
"handoffId": int64(0),
|
||||
"reason": reason,
|
||||
@@ -718,24 +753,29 @@ func (e *Executor) executeHandoffToHuman(state *runState, node dsl.Node) error {
|
||||
"assigneeId": int64(0),
|
||||
"message": "",
|
||||
}
|
||||
if result != nil {
|
||||
output["decision"] = string(result.Decision)
|
||||
output["teamId"] = result.TeamID
|
||||
output["assigneeId"] = result.AssigneeID
|
||||
output["message"] = strings.TrimSpace(result.Message)
|
||||
}
|
||||
output["decision"] = handoff.Decision
|
||||
output["teamId"] = handoff.TeamID
|
||||
output["assigneeId"] = handoff.AssigneeID
|
||||
output["message"] = strings.TrimSpace(handoff.Message)
|
||||
state.setNodeVars(node.ID, output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func workflowToolIdempotencyKey(state *runState, node dsl.Node) string {
|
||||
requestID := strings.TrimSpace(state.input.UserMessage.RequestID)
|
||||
if requestID != "" {
|
||||
return fmt.Sprintf("workflow:%d:node:%s:request:%s", state.input.Conversation.ID, node.ID, requestID)
|
||||
}
|
||||
return fmt.Sprintf("workflow:%d:node:%s:message:%d", state.input.Conversation.ID, node.ID, state.input.UserMessage.ID)
|
||||
}
|
||||
|
||||
func (e *Executor) executeKnowledgeRetrieve(ctx context.Context, state *runState, node dsl.Node) error {
|
||||
query := strings.TrimSpace(toString(state.resolveInput(node, "query")))
|
||||
knowledgeBaseIDs := readInt64ArrayConfig(node.Data.Config, "knowledgeBaseIds")
|
||||
if len(knowledgeBaseIDs) == 0 {
|
||||
return fmt.Errorf("knowledge retrieve node requires knowledgeBaseIds")
|
||||
}
|
||||
retriever := retrievers.NewKnowledgeRetriever(state.input.AIAgent, knowledgeBaseIDs)
|
||||
result, err := retriever.RetrieveContext(ctx, query)
|
||||
_, result, err := readtools.RetrieveKnowledge(ctx, state.input.AIAgent, knowledgeBaseIDs, query, workflowReadToolPolicy(toolx.BuiltinKnowledgeRetrieve.Code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -490,6 +490,43 @@ func TestExecutorResumeCreatesTicketAfterHumanConfirmation(t *testing.T) {
|
||||
if trace == nil || !strings.Contains(trace.OutputPreview, "工单已创建") {
|
||||
t.Fatalf("expected create_ticket output to include customer-visible result message, got %#v", trace)
|
||||
}
|
||||
// Replaying the same confirmation checkpoint must reuse the completed
|
||||
// business-tool invocation rather than creating a second ticket.
|
||||
if _, err := executor.Resume(context.Background(), Input{
|
||||
Definition: createTicketWorkflowDefinition(), Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent,
|
||||
}, interrupted.CheckPointData, "确认"); err != nil {
|
||||
t.Fatalf("replay workflow resume: %v", err)
|
||||
}
|
||||
var ticketCount int64
|
||||
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 1 {
|
||||
t.Fatalf("ticket count after replay = %d, err=%v", ticketCount, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutorDebugResumeDoesNotCreateTicket(t *testing.T) {
|
||||
db := setupWorkflowExecutorHandoffDB(t)
|
||||
aiAgent := createWorkflowExecutorHandoffAIAgent(t, db, "1")
|
||||
conversation := createWorkflowExecutorHandoffConversation(t, db, aiAgent.ID)
|
||||
userMessage := createWorkflowExecutorCustomerMessage(t, db, conversation.ID, "订单支付失败,请帮我登记工单")
|
||||
executor := NewExecutor()
|
||||
definition := createTicketWorkflowDefinition()
|
||||
|
||||
interrupted, err := executor.Execute(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true})
|
||||
if err != nil || !interrupted.Interrupted {
|
||||
t.Fatalf("debug execute = %#v, err=%v", interrupted, err)
|
||||
}
|
||||
result, err := executor.Resume(context.Background(), Input{Definition: definition, Conversation: conversation, UserMessage: userMessage, AIAgent: aiAgent, Debug: true}, interrupted.CheckPointData, "确认")
|
||||
if err != nil || result.Interrupted {
|
||||
t.Fatalf("debug resume = %#v, err=%v", result, err)
|
||||
}
|
||||
var ticketCount int64
|
||||
if err := db.Model(&models.Ticket{}).Where("conversation_id = ?", conversation.ID).Count(&ticketCount).Error; err != nil || ticketCount != 0 {
|
||||
t.Fatalf("debug ticket count = %d, err=%v", ticketCount, err)
|
||||
}
|
||||
trace := findNodeTrace(result.NodeTraces, "create_ticket_1")
|
||||
if trace == nil || !strings.Contains(trace.OutputPreview, "调试运行不会创建工单") {
|
||||
t.Fatalf("expected debug write skip trace, got %#v", trace)
|
||||
}
|
||||
}
|
||||
|
||||
func findNodeTrace(items []NodeTrace, nodeID string) *NodeTrace {
|
||||
@@ -835,6 +872,7 @@ func setupWorkflowExecutorHandoffDB(t *testing.T) *gorm.DB {
|
||||
&models.ConversationReadState{},
|
||||
&models.Message{},
|
||||
&models.ChannelMessageOutbox{},
|
||||
&models.AgentToolInvocation{},
|
||||
&models.Ticket{},
|
||||
&models.TicketNoSequence{},
|
||||
&models.TicketTag{},
|
||||
|
||||
Reference in New Issue
Block a user