From 7a9cffe21eef062e838e7942114c1105f61527fe Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sun, 10 May 2026 00:02:23 +0800 Subject: [PATCH] fix: add structured graph tool results --- .../ai/runtime/executor/event_consumer.go | 12 +- .../runtime/executor/event_consumer_test.go | 150 +++++++++++++----- .../ai/runtime/graphs/create_ticket_graph.go | 17 +- internal/ai/runtime/graphs/handoff_graph.go | 26 ++- .../ai/runtime/graphs/handoff_graph_test.go | 16 +- internal/ai/runtime/tooling/tool_result.go | 42 +++++ .../ai/runtime/tools/handoff_graph_tool.go | 2 +- internal/pkg/toolx/builtin_tools.go | 1 + .../conversation_human_dispatch_service.go | 11 -- ...onversation_human_dispatch_service_test.go | 26 --- 10 files changed, 211 insertions(+), 92 deletions(-) create mode 100644 internal/ai/runtime/tooling/tool_result.go diff --git a/internal/ai/runtime/executor/event_consumer.go b/internal/ai/runtime/executor/event_consumer.go index d5f57f9..09d55cc 100644 --- a/internal/ai/runtime/executor/event_consumer.go +++ b/internal/ai/runtime/executor/event_consumer.go @@ -4,6 +4,7 @@ import ( "strings" "cs-agent/internal/ai/runtime/internal/impl/callbacks" + "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/pkg/enums" "cs-agent/internal/pkg/toolx" @@ -64,10 +65,13 @@ func consumeAgentEvents(events *adk.AsyncIterator[*adk.AgentEvent], summary *Run summary.InvokedToolCodes = appendIfMissing(summary.InvokedToolCodes, toolCode) if strings.TrimSpace(summary.ReplyText) == "" && toolx.ResolveToolSourceType(toolCode) == enums.ToolSourceTypeGraph { toolReplyText := strings.TrimSpace(messageOutput.Message.Content) - if toolReplyText != "" { - summary.ReplyText = toolReplyText - } else if toolCode == toolx.GraphHandoffConversation.Code { - suppressAssistantReply = true + if result, ok := tooling.ParseToolResult(toolReplyText); ok { + if result.ReplyText != "" && !result.ReplySent { + summary.ReplyText = result.ReplyText + } + if result.Terminal && !result.ShouldRetry { + suppressAssistantReply = true + } } } } diff --git a/internal/ai/runtime/executor/event_consumer_test.go b/internal/ai/runtime/executor/event_consumer_test.go index 0450639..18983cf 100644 --- a/internal/ai/runtime/executor/event_consumer_test.go +++ b/internal/ai/runtime/executor/event_consumer_test.go @@ -1,15 +1,17 @@ package executor import ( + "encoding/json" "testing" + "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/pkg/toolx" "github.com/cloudwego/eino/adk" "github.com/cloudwego/eino/schema" ) -func TestConsumeAgentEventsUsesGraphToolTextAsReplyFallback(t *testing.T) { +func TestConsumeAgentEventsIgnoresPlainGraphToolText(t *testing.T) { summary := &RunResult{ Status: "started", InvokedToolCodes: make([]string, 0), @@ -32,7 +34,7 @@ func TestConsumeAgentEventsUsesGraphToolTextAsReplyFallback(t *testing.T) { toolx.GraphHandoffConversation.Name: toolx.GraphHandoffConversation.Code, }) - if summary.ReplyText != "已为你转接人工客服,请稍候。,请稍候。" { + if summary.ReplyText != "" { t.Fatalf("unexpected reply text: %q", summary.ReplyText) } if summary.Status != "completed" { @@ -40,6 +42,109 @@ func TestConsumeAgentEventsUsesGraphToolTextAsReplyFallback(t *testing.T) { } } +func TestConsumeAgentEventsUsesGraphToolResultReplyText(t *testing.T) { + summary := &RunResult{ + Status: "started", + InvokedToolCodes: make([]string, 0), + } + payload, err := json.Marshal(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "off_hours_handoff", + ReplyText: "当前暂不在人工客服服务时间内,你可以先继续描述问题。", + ShouldRetry: false, + }) + if err != nil { + t.Fatalf("marshal graph tool result: %v", err) + } + events, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Tool, + ToolName: toolx.GraphHandoffConversation.Name, + Message: &schema.Message{ + Content: string(payload), + }, + }, + }, + }) + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Assistant, + Message: &schema.Message{ + Content: "我再试一次转人工。", + }, + }, + }, + }) + gen.Close() + + consumeAgentEvents(events, summary, nil, map[string]string{ + toolx.GraphHandoffConversation.Name: toolx.GraphHandoffConversation.Code, + }) + + if summary.ReplyText != "当前暂不在人工客服服务时间内,你可以先继续描述问题。" { + t.Fatalf("unexpected reply text: %q", summary.ReplyText) + } + if summary.Status != "completed" { + t.Fatalf("unexpected summary status: %q", summary.Status) + } +} + +func TestConsumeAgentEventsSuppressesGraphToolResultWhenReplyAlreadySent(t *testing.T) { + summary := &RunResult{ + Status: "started", + InvokedToolCodes: make([]string, 0), + } + payload, err := json.Marshal(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "off_hours_handoff", + ReplyText: "当前暂不在人工客服服务时间内,你可以先继续描述问题。", + ReplySent: true, + ShouldRetry: false, + }) + if err != nil { + t.Fatalf("marshal graph tool result: %v", err) + } + events, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Tool, + ToolName: toolx.GraphHandoffConversation.Name, + Message: &schema.Message{ + Content: string(payload), + }, + }, + }, + }) + gen.Send(&adk.AgentEvent{ + Output: &adk.AgentOutput{ + MessageOutput: &adk.MessageVariant{ + Role: schema.Assistant, + Message: &schema.Message{ + Content: "我再试一次转人工。", + }, + }, + }, + }) + gen.Close() + + consumeAgentEvents(events, summary, nil, map[string]string{ + toolx.GraphHandoffConversation.Name: toolx.GraphHandoffConversation.Code, + }) + + if summary.ReplyText != "" { + t.Fatalf("expected no committed reply because graph already sent it, got %q", summary.ReplyText) + } + if summary.Status != "completed" { + t.Fatalf("unexpected summary status: %q", summary.Status) + } +} + func TestConsumeAgentEventsCompletesGraphToolWithNoVisibleReply(t *testing.T) { summary := &RunResult{ Status: "started", @@ -70,44 +175,3 @@ func TestConsumeAgentEventsCompletesGraphToolWithNoVisibleReply(t *testing.T) { t.Fatalf("unexpected summary status: %q", summary.Status) } } - -func TestConsumeAgentEventsSuppressesAssistantReplyAfterSilentHandoffTool(t *testing.T) { - summary := &RunResult{ - Status: "started", - InvokedToolCodes: make([]string, 0), - } - events, gen := adk.NewAsyncIteratorPair[*adk.AgentEvent]() - gen.Send(&adk.AgentEvent{ - Output: &adk.AgentOutput{ - MessageOutput: &adk.MessageVariant{ - Role: schema.Tool, - ToolName: toolx.GraphHandoffConversation.Name, - Message: &schema.Message{ - Content: "", - }, - }, - }, - }) - gen.Send(&adk.AgentEvent{ - Output: &adk.AgentOutput{ - MessageOutput: &adk.MessageVariant{ - Role: schema.Assistant, - Message: &schema.Message{ - Content: "好的,已为您发起转接人工客服的请求。系统正在为您确认,请稍候。", - }, - }, - }, - }) - gen.Close() - - consumeAgentEvents(events, summary, nil, map[string]string{ - toolx.GraphHandoffConversation.Name: toolx.GraphHandoffConversation.Code, - }) - - if summary.ReplyText != "" { - t.Fatalf("expected assistant reply after silent handoff to be suppressed, got %q", summary.ReplyText) - } - if summary.Status != "completed" { - t.Fatalf("unexpected summary status: %q", summary.Status) - } -} diff --git a/internal/ai/runtime/graphs/create_ticket_graph.go b/internal/ai/runtime/graphs/create_ticket_graph.go index 41e588a..ed5a25d 100644 --- a/internal/ai/runtime/graphs/create_ticket_graph.go +++ b/internal/ai/runtime/graphs/create_ticket_graph.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/models" "cs-agent/internal/pkg/dto" "cs-agent/internal/pkg/dto/request" @@ -84,9 +85,21 @@ func (g *CreateTicketGraph) Run(ctx context.Context, argumentsInJSON string) (st if err != nil { return "", err } - return fmt.Sprintf("工单已创建,工单号:%s,标题:%s。", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)), nil + return tooling.MarshalToolResult(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "ticket_created", + ReplyText: fmt.Sprintf("工单已创建,工单号:%s,标题:%s。", strings.TrimSpace(item.TicketNo), strings.TrimSpace(item.Title)), + ShouldRetry: false, + }), nil case ConfirmationDecisionCancel: - return CancelCreateTicketReply, nil + return tooling.MarshalToolResult(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "ticket_cancelled", + ReplyText: CancelCreateTicketReply, + ShouldRetry: false, + }), nil default: info := CreateTicketGraphInterruptInfo{ Type: InterruptTypeTicketCreationConfirmation, diff --git a/internal/ai/runtime/graphs/handoff_graph.go b/internal/ai/runtime/graphs/handoff_graph.go index e3538e8..8b6a21e 100644 --- a/internal/ai/runtime/graphs/handoff_graph.go +++ b/internal/ai/runtime/graphs/handoff_graph.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/models" "cs-agent/internal/services" @@ -53,7 +54,14 @@ func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, handled, err := services.ConversationService.TryOffHoursHandoffByAI(g.conversation.ID, g.aiAgent, reason) if err != nil || handled { if handled && err == nil { - return services.HandoffOffHoursMessage, nil + return tooling.MarshalToolResult(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "off_hours_handoff", + ReplyText: services.HandoffOffHoursMessage, + ReplySent: true, + ShouldRetry: false, + }), nil } return "", err } @@ -87,9 +95,21 @@ func (g *HandoffGraph) Run(ctx context.Context, argumentsInJSON string) (string, return "", err } // ConversationService sends the customer-visible handoff notice according to the dispatch decision. - return "", nil + return tooling.MarshalToolResult(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "handoff_confirmed", + ReplySent: true, + ShouldRetry: false, + }), nil case ConfirmationDecisionCancel: - return CancelHandoffReply, nil + return tooling.MarshalToolResult(tooling.ToolResult{ + Handled: true, + Terminal: true, + Action: "handoff_cancelled", + ReplyText: CancelHandoffReply, + ShouldRetry: false, + }), nil default: info := HandoffGraphInterruptInfo{ Type: InterruptTypeHandoffConfirmation, diff --git a/internal/ai/runtime/graphs/handoff_graph_test.go b/internal/ai/runtime/graphs/handoff_graph_test.go index 254e20e..1f5b28e 100644 --- a/internal/ai/runtime/graphs/handoff_graph_test.go +++ b/internal/ai/runtime/graphs/handoff_graph_test.go @@ -2,10 +2,12 @@ package graphs import ( "context" + "encoding/json" "strings" "testing" "time" + "cs-agent/internal/ai/runtime/tooling" "cs-agent/internal/models" "cs-agent/internal/pkg/enums" "cs-agent/internal/services" @@ -25,8 +27,18 @@ func TestHandoffGraphOffHoursSendsNoticeWithoutConfirmation(t *testing.T) { if err != nil { t.Fatalf("Run() error = %v", err) } - if reply != services.HandoffOffHoursMessage { - t.Fatalf("expected off-hours graph reply, got %q", reply) + var result tooling.ToolResult + if err := json.Unmarshal([]byte(reply), &result); err != nil { + t.Fatalf("expected graph tool result JSON, got %q: %v", reply, err) + } + if !result.Handled || !result.Terminal || result.ShouldRetry { + t.Fatalf("unexpected graph result flags: %+v", result) + } + if !result.ReplySent { + t.Fatalf("expected graph result to mark replySent, got %+v", result) + } + if result.Action != "off_hours_handoff" || result.ReplyText != services.HandoffOffHoursMessage { + t.Fatalf("unexpected off-hours graph result: %+v", result) } message := services.MessageService.FindOne(sqls.NewCnd().Eq("conversation_id", conversation.ID).Desc("id")) diff --git a/internal/ai/runtime/tooling/tool_result.go b/internal/ai/runtime/tooling/tool_result.go new file mode 100644 index 0000000..7131629 --- /dev/null +++ b/internal/ai/runtime/tooling/tool_result.go @@ -0,0 +1,42 @@ +package tooling + +import ( + "encoding/json" + "strings" +) + +type ToolResult struct { + Handled bool `json:"handled"` + Terminal bool `json:"terminal"` + Action string `json:"action"` + ReplyText string `json:"replyText,omitempty"` + ReplySent bool `json:"replySent,omitempty"` + ShouldRetry bool `json:"shouldRetry"` +} + +func MarshalToolResult(result ToolResult) string { + result.Action = strings.TrimSpace(result.Action) + result.ReplyText = strings.TrimSpace(result.ReplyText) + buf, err := json.Marshal(result) + if err != nil { + return "" + } + return string(buf) +} + +func ParseToolResult(raw string) (ToolResult, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return ToolResult{}, false + } + var result ToolResult + if err := json.Unmarshal([]byte(raw), &result); err != nil { + return ToolResult{}, false + } + result.Action = strings.TrimSpace(result.Action) + result.ReplyText = strings.TrimSpace(result.ReplyText) + if result.Action == "" && result.ReplyText == "" && !result.Handled && !result.Terminal { + return ToolResult{}, false + } + return result, true +} diff --git a/internal/ai/runtime/tools/handoff_graph_tool.go b/internal/ai/runtime/tools/handoff_graph_tool.go index 776d366..fb1e68e 100644 --- a/internal/ai/runtime/tools/handoff_graph_tool.go +++ b/internal/ai/runtime/tools/handoff_graph_tool.go @@ -52,7 +52,7 @@ func (t *HandoffGraphTool) Build(ctx registry.Context) (einotool.BaseTool, error func (t *HandoffGraphTool) Info(ctx context.Context) (*schema.ToolInfo, error) { return &schema.ToolInfo{ Name: toolx.GraphHandoffConversation.Name, - Desc: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。仅在用户明确要求人工客服,或你已确认必须转人工处理时调用。", + Desc: "Graph Tool。用于封装转人工原因整理、用户确认、真正转人工和结果返回的确定性流程。仅在用户明确要求人工客服,或你已确认必须转人工处理时调用;若结果标记 terminal=true 且 shouldRetry=false,不要重复调用。", ParamsOneOf: schema.NewParamsOneOfByJSONSchema(&einojsonschema.Schema{ Version: einojsonschema.Version, Type: "object", diff --git a/internal/pkg/toolx/builtin_tools.go b/internal/pkg/toolx/builtin_tools.go index a037ad6..9d9ac79 100644 --- a/internal/pkg/toolx/builtin_tools.go +++ b/internal/pkg/toolx/builtin_tools.go @@ -137,6 +137,7 @@ var ( 3. 一旦决定转人工,必须调用 handoff_to_human 工具,禁止只在回复里口头说“我帮你转人工了”。 4. 该 Graph Tool 会先向用户发起确认。用户确认后才会真正转人工;用户取消则结束本次转人工流程。 5. 如果问题仍可由当前对话继续解决,优先继续解答,不要过早转人工。 +6. 如果工具返回 terminal=true 且 shouldRetry=false,说明转人工流程已经结束,禁止重复调用该工具。 `), } RegisteredToolSpecs = []ToolSpec{ diff --git a/internal/services/conversation_human_dispatch_service.go b/internal/services/conversation_human_dispatch_service.go index 5c15d77..93fb87f 100644 --- a/internal/services/conversation_human_dispatch_service.go +++ b/internal/services/conversation_human_dispatch_service.go @@ -58,9 +58,6 @@ func (s *conversationHumanDispatchService) TryOffHoursHandoffByAI(conversationID if err := s.createEvent(conversationID, enums.IMEventTypeTransfer, enums.IMSenderTypeAI, aiAgent.ID, "转人工失败:非服务时间", strings.TrimSpace(reason)); err != nil { return true, err } - if s.hasLatestAIText(conversationID, HandoffOffHoursMessage) { - return true, nil - } if err := s.sendAIText(conversationID, aiAgent.ID, HandoffOffHoursMessage); err != nil { return true, err } @@ -291,14 +288,6 @@ func (s *conversationHumanDispatchService) sendAIText(conversationID, aiAgentID return err } -func (s *conversationHumanDispatchService) hasLatestAIText(conversationID int64, content string) bool { - latest, err := MessageService.GetConversationReadTarget(conversationID, 0) - if err != nil || latest == nil { - return false - } - return latest.SenderType == enums.IMSenderTypeAI && strings.TrimSpace(latest.Content) == strings.TrimSpace(content) -} - func orderedPositiveIDs(value string) []int64 { return uniquePositiveInt64sFromStrings(strings.Split(value, ",")) } diff --git a/internal/services/conversation_human_dispatch_service_test.go b/internal/services/conversation_human_dispatch_service_test.go index b3b1497..f44a387 100644 --- a/internal/services/conversation_human_dispatch_service_test.go +++ b/internal/services/conversation_human_dispatch_service_test.go @@ -47,32 +47,6 @@ func TestConversationHumanDispatchAIHandoffOffHoursKeepsAIServingAndSendsNotice( } } -func TestConversationHumanDispatchAIHandoffOffHoursDoesNotDuplicateNotice(t *testing.T) { - db := setupConversationHumanDispatchTestDB(t) - aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1") - conversation := createHumanDispatchConversation(t, db, aiAgent.ID, enums.IMConversationStatusAIServing) - - for i := 0; i < 3; i++ { - result, err := services.ConversationHumanDispatchService.HandoffByAI(conversation.ID, aiAgent, "用户要求转人工") - if err != nil { - t.Fatalf("HandoffByAI() round %d error = %v", i+1, err) - } - if result == nil || result.Decision != services.HandoffDecisionOffHours { - t.Fatalf("expected off_hours decision on round %d, got %+v", i+1, result) - } - } - - var count int64 - if err := db.Model(&models.Message{}). - Where("conversation_id = ? AND sender_type = ? AND content = ?", conversation.ID, enums.IMSenderTypeAI, services.HandoffOffHoursMessage). - Count(&count).Error; err != nil { - t.Fatalf("count off-hours messages error = %v", err) - } - if count != 1 { - t.Fatalf("expected one off-hours notice, got %d", count) - } -} - func TestConversationHumanDispatchAIHandoffAssignsAvailableAgent(t *testing.T) { db := setupConversationHumanDispatchTestDB(t) aiAgent := createHumanDispatchAIAgent(t, db, enums.IMConversationServiceModeAIFirst, "1")