feat: implement Handoff Graph Tool for managing AI to human transitions

This commit is contained in:
mlogclub
2026-04-10 16:00:10 +08:00
parent d953625a2b
commit 15afa10c7d
9 changed files with 307 additions and 24 deletions
+148
View File
@@ -0,0 +1,148 @@
package graphs
import (
"context"
"encoding/json"
"fmt"
"strings"
"cs-agent/internal/models"
"cs-agent/internal/services"
componenttool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
type HandoffGraphState struct {
Reason string `json:"reason"`
}
type HandoffGraphInterruptInfo struct {
Type string `json:"type"`
Message string `json:"message"`
}
func init() {
schema.RegisterName[HandoffGraphState]("cs_agent_handoff_graph_state")
schema.RegisterName[HandoffGraphInterruptInfo]("cs_agent_handoff_graph_interrupt_info")
}
type HandoffGraph struct {
conversation *models.Conversation
aiAgent *models.AIAgent
}
func NewHandoffGraph(conversation *models.Conversation, aiAgent *models.AIAgent) *HandoffGraph {
return &HandoffGraph{
conversation: conversation,
aiAgent: aiAgent,
}
}
func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, error) {
if g == nil || g.conversation == nil || g.aiAgent == nil {
return "", fmt.Errorf("handoff graph not initialized")
}
wasInterrupted, hasState, state := componenttool.GetInterruptState[HandoffGraphState](ctx)
if !wasInterrupted {
reason, err := g.buildReason(argumentsInJSON)
if err != nil {
return "", err
}
info := HandoffGraphInterruptInfo{
Type: "handoff_confirmation",
Message: g.buildConfirmationPrompt(reason),
}
return "", componenttool.StatefulInterrupt(ctx, info, HandoffGraphState{Reason: reason})
}
if !hasState {
return "", fmt.Errorf("handoff graph state missing")
}
isResumeTarget, hasData, resumeText := componenttool.GetResumeContext[string](ctx)
if !isResumeTarget {
info := HandoffGraphInterruptInfo{
Type: "handoff_confirmation",
Message: g.buildConfirmationPrompt(state.Reason),
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
if !hasData {
info := HandoffGraphInterruptInfo{
Type: "handoff_confirmation",
Message: "请回复“确认”或“取消”。",
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
switch parseHandoffDecision(resumeText) {
case graphDecisionConfirm:
if err := services.ConversationService.HandoffByAI(g.conversation.ID, g.aiAgent, state.Reason); err != nil {
return "", err
}
return "已为你转接人工客服,请稍候。", nil
case graphDecisionCancel:
return "已取消本次转人工。", nil
default:
info := HandoffGraphInterruptInfo{
Type: "handoff_confirmation",
Message: "我需要你的明确确认,请直接回复“确认”或“取消”。",
}
return "", componenttool.StatefulInterrupt(ctx, info, state)
}
}
func (g *HandoffGraph) buildReason(argumentsInJSON string) (string, error) {
reason := "用户需要转人工支持"
raw := make(map[string]any)
if strings.TrimSpace(argumentsInJSON) != "" {
if err := json.Unmarshal([]byte(argumentsInJSON), &raw); err != nil {
return "", fmt.Errorf("invalid handoff arguments: %w", err)
}
}
if parsed := strings.TrimSpace(graphGetStringValue(raw, "reason")); parsed != "" {
reason = parsed
}
return reason, nil
}
func (g *HandoffGraph) buildConfirmationPrompt(reason string) string {
return fmt.Sprintf("我准备为你转接人工客服。\n原因:%s\n请直接回复“确认”或“取消”。", strings.TrimSpace(reason))
}
type graphDecision string
const (
graphDecisionConfirm graphDecision = "confirm"
graphDecisionCancel graphDecision = "cancel"
)
func parseHandoffDecision(value string) graphDecision {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return ""
}
confirmWords := []string{"确认", "是", "好的", "可以", "ok", "yes", "继续", "同意"}
for _, item := range confirmWords {
if strings.Contains(value, item) {
return graphDecisionConfirm
}
}
cancelWords := []string{"取消", "不用", "不需要", "算了", "no"}
for _, item := range cancelWords {
if strings.Contains(value, item) {
return graphDecisionCancel
}
}
return ""
}
func graphGetStringValue(data map[string]any, key string) string {
if len(data) == 0 {
return ""
}
value, ok := data[key]
if !ok || value == nil {
return ""
}
text, _ := value.(string)
return text
}
@@ -83,6 +83,9 @@ func (f *AgentFactory) BuildCustomerServiceAgent(ctx context.Context, aiAgent *m
} else if toolCode == toolx.GraphCreateTicketConfirmToolCode {
serverCode = toolx.GraphToolCatalogServerCode
toolName = toolx.GraphCreateTicketConfirmToolName
} else if toolCode == toolx.GraphHandoffConversationToolCode {
serverCode = toolx.GraphToolCatalogServerCode
toolName = toolx.GraphHandoffConversationToolName
}
toolMetadataBy[modelName] = einocallbacks.ToolMetadata{
ToolCode: toolCode,
@@ -150,6 +153,16 @@ func buildAgentInstruction(aiAgent *models.AIAgent, selectedSkill *models.SkillD
3. 一旦准备创建工单,必须调用 create_ticket_with_confirmation 工具,禁止直接口头宣称“已经创建工单”。
4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正创建工单;用户取消则结束本次建单流程。
5. 如果用户只是咨询、抱怨或泛泛表达不满,但没有明确要求建单,优先继续澄清,不要主动创建工单。
`))
}
if hasToolCode(extraToolCodes, toolx.GraphHandoffConversationToolCode) {
appendixParts = append(appendixParts, strings.TrimSpace(`
你可以在确认需要人工介入后调用 handoff_to_human 这个 Graph Tool 来转人工,但必须遵守以下规则:
1. 只有在用户明确要求人工客服,或你已经判断该问题必须由人工继续处理时,才调用该工具。
2. 调用前先尽量整理清楚转人工原因;如果理由含糊,先追问或澄清,不要直接转人工。
3. 一旦决定转人工,必须调用 handoff_to_human 工具,禁止只在回复里口头说“我帮你转人工了”。
4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正转人工;用户取消则结束本次转人工流程。
5. 如果问题仍可由当前对话继续解决,优先继续解答,不要过早转人工。
`))
}
projectRoot, _ := os.Getwd()
+1 -22
View File
@@ -673,33 +673,12 @@ func isCheckpointMissingError(err error) bool {
}
func (s *aiReplyService) handoffConversation(conversation models.Conversation, aiAgent models.AIAgent, reason string) error {
now := time.Now()
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversation.ID, map[string]any{
"handoff_at": now,
"handoff_reason": strings.TrimSpace(reason),
"status": enums.IMConversationStatusPending,
"current_team_id": 0,
"current_assignee_id": 0,
"update_user_id": 0,
"update_user_name": aiAgent.Name,
"updated_at": now,
}); err != nil {
return err
}
return svc.ConversationEventLogService.CreateEvent(ctx, conversation.ID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason))
}); err != nil {
if err := svc.ConversationService.HandoffByAI(conversation.ID, &aiAgent, reason); err != nil {
return err
}
if _, err := svc.MessageService.SendAIMessage(conversation.ID, aiAgent.ID, fmt.Sprintf("ai_handoff_%d", conversation.LastMessageID), enums.IMMessageTypeText, "已为你转接人工客服,请稍候。", "", s.buildAIPrincipal(aiAgent)); err != nil {
return err
}
if _, err := svc.ConversationDispatchService.DispatchConversation(conversation.ID); err != nil {
slog.Warn("auto dispatch conversation after ai handoff failed",
"conversation_id", conversation.ID,
"ai_agent_id", aiAgent.ID,
"error", err)
}
return nil
}
+1
View File
@@ -20,6 +20,7 @@ func newService() *service {
runtime: engine.NewService(),
registry: registry.NewRegistry(
tools.NewCreateTicketGraphTool(),
tools.NewHandoffGraphTool(),
),
}
}
@@ -0,0 +1,83 @@
package tools
import (
"context"
"fmt"
"cs-agent/internal/ai/runtime/graphs"
"cs-agent/internal/ai/runtime/registry"
"cs-agent/internal/models"
"cs-agent/internal/pkg/toolx"
einotool "github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
einojsonschema "github.com/eino-contrib/jsonschema"
orderedmap "github.com/wk8/go-ordered-map/v2"
)
const (
HandoffConversationToolCode = toolx.GraphHandoffConversationToolCode
HandoffConversationToolName = toolx.GraphHandoffConversationToolName
)
type HandoffGraphTool struct {
conversation *models.Conversation
aiAgent *models.AIAgent
}
func NewHandoffGraphTool() *HandoffGraphTool {
return &HandoffGraphTool{}
}
func (t *HandoffGraphTool) Name() string {
return HandoffConversationToolName
}
func (t *HandoffGraphTool) Code() string {
return HandoffConversationToolCode
}
func (t *HandoffGraphTool) Enabled(ctx registry.Context) bool {
return ctx.Conversation != nil && ctx.AIAgent != nil
}
func (t *HandoffGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error) {
if !t.Enabled(ctx) {
return nil, nil
}
return &HandoffGraphTool{
conversation: ctx.Conversation,
aiAgent: ctx.AIAgent,
}, nil
}
func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: HandoffConversationToolName,
Desc: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。仅在用户明确要求人工客服,或你已确认必须转人工处理时调用。",
ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{
Version: einojsonschema.Version,
Type: "object",
Properties: orderedmap.New[string, *einojsonschema.Schema](orderedmap.WithInitialData(
orderedmap.Pair[string, *einojsonschema.Schema]{
Key: "reason",
Value: &einojsonschema.Schema{
Type: "string",
Description: "转人工原因,简洁说明为何需要人工介入,例如用户明确要求人工、问题需要人工核验、需要人工售后处理等。",
},
},
)),
}),
Extra: map[string]any{
"toolCode": HandoffConversationToolCode,
"sourceType": "graph",
},
}, nil
}
func (t *HandoffGraphTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...einotool.Option) (string, error) {
if t == nil || t.conversation == nil || t.aiAgent == nil {
return "", fmt.Errorf("handoff graph tool not initialized")
}
return graphs.NewHandoffGraph(t.conversation, t.aiAgent).Run(ctx, argumentsInJSON)
}
@@ -186,6 +186,9 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
} else if toolCode == toolx.GraphCreateTicketConfirmToolCode {
serverCode = toolx.GraphToolCatalogServerCode
toolName = toolx.GraphCreateTicketConfirmToolName
} else if toolCode == toolx.GraphHandoffConversationToolCode {
serverCode = toolx.GraphToolCatalogServerCode
toolName = toolx.GraphHandoffConversationToolName
} else if parsedServerCode, parsedToolName := toolx.SplitMCPToolCode(toolCode); parsedServerCode != "" && parsedToolName != "" {
serverCode = parsedServerCode
toolName = parsedToolName
@@ -197,6 +200,8 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
title = toolx.BuiltinToolSearchToolTitle
case toolx.GraphCreateTicketConfirmToolCode:
title = toolx.GraphCreateTicketConfirmToolTitle
case toolx.GraphHandoffConversationToolCode:
title = toolx.GraphHandoffConversationToolTitle
}
}
description := strings.TrimSpace(tool.Description)
@@ -206,6 +211,8 @@ func buildAIAgentResponse(item *models.AIAgent) response.AIAgentResponse {
description = toolx.BuiltinToolSearchToolDescription
case toolx.GraphCreateTicketConfirmToolCode:
description = toolx.GraphCreateTicketConfirmToolDescription
case toolx.GraphHandoffConversationToolCode:
description = toolx.GraphHandoffConversationToolDescription
}
}
ret.DirectTools = append(ret.DirectTools, response.AIAgentMCPToolResponse{
+4
View File
@@ -11,6 +11,10 @@ const (
GraphCreateTicketConfirmToolName = "create_ticket_with_confirmation"
GraphCreateTicketConfirmToolTitle = "创建工单确认流程"
GraphCreateTicketConfirmToolDescription = "Graph Tool。用于封装建单参数整理、用户确认、真正建单和结果返回的确定性流程。"
GraphHandoffConversationToolCode = "graph/handoff_to_human"
GraphHandoffConversationToolName = "handoff_to_human"
GraphHandoffConversationToolTitle = "转人工确认流程"
GraphHandoffConversationToolDescription = "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。"
BuiltinCreateTicketConfirmToolCode = "builtin/create_ticket_with_confirmation"
BuiltinCreateTicketConfirmToolName = "create_ticket_with_confirmation"
BuiltinCreateTicketConfirmToolTitle = "创建工单并发起确认"
+39
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"cs-agent/internal/models"
"cs-agent/internal/pkg/constants"
@@ -284,6 +285,44 @@ func (s *conversationService) TransferConversation(conversationID, toUserID int6
return nil
}
func (s *conversationService) HandoffByAI(conversationID int64, aiAgent *models.AIAgent, reason string) error {
if conversationID <= 0 {
return errorsx.InvalidParam("会话不存在")
}
if aiAgent == nil {
return errorsx.InvalidParam("AI Agent 不存在")
}
now := time.Now()
if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error {
conversation := repositories.ConversationRepository.Get(ctx.Tx, conversationID)
if conversation == nil {
return errorsx.InvalidParam("会话不存在")
}
if err := repositories.ConversationRepository.Updates(ctx.Tx, conversationID, map[string]any{
"handoff_at": now,
"handoff_reason": strings.TrimSpace(reason),
"status": enums.IMConversationStatusPending,
"current_team_id": 0,
"current_assignee_id": 0,
"update_user_id": 0,
"update_user_name": aiAgent.Name,
"updated_at": now,
}); err != nil {
return err
}
return ConversationEventLogService.CreateEvent(ctx, conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "AI转人工", strings.TrimSpace(reason))
}); err != nil {
return err
}
if _, err := ConversationDispatchService.DispatchConversation(conversationID); err != nil {
slog.Warn("auto dispatch conversation after ai handoff failed",
"conversation_id", conversationID,
"ai_agent_id", aiAgent.ID,
"error", err)
}
return nil
}
func (s *conversationService) CloseConversation(conversationID int64, closeReason string, operator *dto.AuthPrincipal) error {
if operator == nil {
return errorsx.Unauthorized("未登录或登录已过期")
+11 -2
View File
@@ -33,7 +33,7 @@ type MCPToolCatalogItem struct {
func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalogItem, error) {
cfg := config.Current()
ret := make([]MCPToolCatalogItem, 0, 2)
ret := make([]MCPToolCatalogItem, 0, 3)
ret = append(ret, MCPToolCatalogItem{
ToolCode: toolx.GraphCreateTicketConfirmToolCode,
ServerCode: toolx.GraphToolCatalogServerCode,
@@ -43,6 +43,15 @@ func (s *toolCatalogService) ListMCPTools(ctx context.Context) ([]MCPToolCatalog
Title: toolx.GraphCreateTicketConfirmToolTitle,
Description: toolx.GraphCreateTicketConfirmToolDescription,
})
ret = append(ret, MCPToolCatalogItem{
ToolCode: toolx.GraphHandoffConversationToolCode,
ServerCode: toolx.GraphToolCatalogServerCode,
ToolName: toolx.GraphHandoffConversationToolName,
SourceType: toolx.GraphToolCatalogServerCode,
AutoInjected: false,
Title: toolx.GraphHandoffConversationToolTitle,
Description: toolx.GraphHandoffConversationToolDescription,
})
if !cfg.MCP.Enabled {
return ret, nil
}
@@ -96,7 +105,7 @@ func (s *toolCatalogService) ValidateToolCode(toolCode string) error {
return errorsx.InvalidParam("toolCode不能为空")
}
switch toolCode {
case toolx.BuiltinToolSearchToolCode, toolx.BuiltinCreateTicketConfirmToolCode, toolx.GraphCreateTicketConfirmToolCode:
case toolx.BuiltinToolSearchToolCode, toolx.BuiltinCreateTicketConfirmToolCode, toolx.GraphCreateTicketConfirmToolCode, toolx.GraphHandoffConversationToolCode:
return nil
}
serverCode, toolName := toolx.SplitMCPToolCode(toolCode)