2026-04-10 16:00:10 +08:00
package graphs
import (
"context"
"encoding/json"
"fmt"
"strings"
2026-05-31 18:43:48 +08:00
"agent-desk/internal/ai/runtime/tooling"
"agent-desk/internal/models"
"agent-desk/internal/pkg/tracex"
"agent-desk/internal/services"
2026-04-10 16:00:10 +08:00
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"`
}
2026-04-14 11:08:02 +08:00
type handoffGraphArgs struct {
Reason string `json:"reason"`
}
2026-04-10 16:00:10 +08:00
func init () {
2026-05-30 21:19:36 +08:00
schema . RegisterName [ HandoffGraphState ]( "cs_ai_agent_handoff_graph_state" )
schema . RegisterName [ HandoffGraphInterruptInfo ]( "cs_ai_agent_handoff_graph_interrupt_info" )
2026-04-10 16:00:10 +08:00
}
type HandoffGraph struct {
2026-04-17 18:48:45 +08:00
conversation models . Conversation
2026-04-17 17:57:01 +08:00
aiAgent models . AIAgent
2026-04-10 16:00:10 +08:00
}
2026-04-17 18:48:45 +08:00
func NewHandoffGraph ( conversation models . Conversation , aiAgent models . AIAgent ) * HandoffGraph {
2026-04-10 16:00:10 +08:00
return & HandoffGraph {
conversation : conversation ,
aiAgent : aiAgent ,
}
}
func ( g * HandoffGraph ) Run ( ctx context . Context , argumentsInJSON string ) ( string , error ) {
wasInterrupted , hasState , state := componenttool . GetInterruptState [ HandoffGraphState ]( ctx )
if ! wasInterrupted {
reason , err := g . buildReason ( argumentsInJSON )
if err != nil {
return "" , err
}
2026-05-27 22:18:43 +08:00
requestID := tracex . RequestIDFromContext ( ctx )
handled , err := services . ConversationService . TryOffHoursHandoffByAIWithRequestID ( g . conversation . ID , g . aiAgent , reason , requestID )
2026-05-08 23:26:25 +08:00
if err != nil || handled {
2026-05-09 23:46:56 +08:00
if handled && err == nil {
2026-05-10 00:02:23 +08:00
return tooling . MarshalToolResult ( tooling . ToolResult {
Handled : true ,
Terminal : true ,
Action : "off_hours_handoff" ,
ReplyText : services . HandoffOffHoursMessage ,
ReplySent : true ,
ShouldRetry : false ,
}), nil
2026-05-09 23:46:56 +08:00
}
2026-05-08 23:26:25 +08:00
return "" , err
}
2026-04-10 16:00:10 +08:00
info := HandoffGraphInterruptInfo {
2026-04-10 16:24:49 +08:00
Type : InterruptTypeHandoffConfirmation ,
2026-04-10 16:00:10 +08:00
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 {
2026-04-10 16:24:49 +08:00
Type : InterruptTypeHandoffConfirmation ,
2026-04-10 16:00:10 +08:00
Message : g . buildConfirmationPrompt ( state . Reason ),
}
return "" , componenttool . StatefulInterrupt ( ctx , info , state )
}
if ! hasData {
info := HandoffGraphInterruptInfo {
2026-04-10 16:24:49 +08:00
Type : InterruptTypeHandoffConfirmation ,
Message : ConfirmOrCancelPrompt ,
2026-04-10 16:00:10 +08:00
}
return "" , componenttool . StatefulInterrupt ( ctx , info , state )
}
switch parseHandoffDecision ( resumeText ) {
2026-04-10 16:24:49 +08:00
case ConfirmationDecisionConfirm :
2026-05-27 22:18:43 +08:00
if err := services . ConversationService . HandoffByAIWithRequestID ( g . conversation . ID , g . aiAgent , state . Reason , tracex . RequestIDFromContext ( ctx )); err != nil {
2026-04-10 16:00:10 +08:00
return "" , err
}
2026-05-08 23:13:32 +08:00
// ConversationService sends the customer-visible handoff notice according to the dispatch decision.
2026-05-10 00:02:23 +08:00
return tooling . MarshalToolResult ( tooling . ToolResult {
Handled : true ,
Terminal : true ,
Action : "handoff_confirmed" ,
ReplySent : true ,
ShouldRetry : false ,
}), nil
2026-04-10 16:24:49 +08:00
case ConfirmationDecisionCancel :
2026-05-10 00:02:23 +08:00
return tooling . MarshalToolResult ( tooling . ToolResult {
Handled : true ,
Terminal : true ,
Action : "handoff_cancelled" ,
ReplyText : CancelHandoffReply ,
ShouldRetry : false ,
}), nil
2026-04-10 16:00:10 +08:00
default :
info := HandoffGraphInterruptInfo {
2026-04-10 16:24:49 +08:00
Type : InterruptTypeHandoffConfirmation ,
Message : NeedExplicitConfirmationPrompt ,
2026-04-10 16:00:10 +08:00
}
return "" , componenttool . StatefulInterrupt ( ctx , info , state )
}
}
func ( g * HandoffGraph ) buildReason ( argumentsInJSON string ) ( string , error ) {
2026-05-31 20:06:44 +08:00
reason := "The user needs human support."
2026-04-14 11:08:02 +08:00
var args handoffGraphArgs
2026-04-10 16:00:10 +08:00
if strings . TrimSpace ( argumentsInJSON ) != "" {
2026-04-14 11:08:02 +08:00
if err := json . Unmarshal ([] byte ( argumentsInJSON ), & args ); err != nil {
2026-04-10 16:00:10 +08:00
return "" , fmt . Errorf ( "invalid handoff arguments: %w" , err )
}
}
2026-04-14 11:08:02 +08:00
if parsed := strings . TrimSpace ( args . Reason ); parsed != "" {
2026-04-10 16:00:10 +08:00
reason = parsed
}
return reason , nil
}
func ( g * HandoffGraph ) buildConfirmationPrompt ( reason string ) string {
2026-05-31 20:06:44 +08:00
return fmt . Sprintf ( "I am ready to connect you to a human support agent.\nReason: %s\nPlease reply with \"Confirm\" or \"Cancel\"." , strings . TrimSpace ( reason ))
2026-04-10 16:00:10 +08:00
}
2026-04-10 16:24:49 +08:00
func parseHandoffDecision ( value string ) ConfirmationDecision {
return ParseConfirmationDecision ( value )
2026-04-10 16:00:10 +08:00
}