This introduces a small event layer for the Captain V2 conversation lifecycle. A new `Captain::ConversationEvents` facade dispatches five normalized events (`captain.conversation.engaged`, `captain.conversation.handed_off`, `captain.conversation.resolved`, `captain.response.completed`, `captain.response.failed`) from the points where Captain engages a conversation, replies, fails, hands off, or auto-resolves. Each event carries the conversation, assistant, timestamp, and a `source`/`reason_category` where relevant. ## Why this, why now The Captain V2 flow is about to gain several observers at once: conversation outcome tracking, agent session capture, and analytics all need to know when Captain engages, replies, fails, hands off, or resolves. Wiring each of them directly into `ResponseBuilderJob`, `HookExecutionService`, and the tools would tangle secondary bookkeeping into the paths that deliver customer-facing behavior, and every future consumer would deepen that. Landing the event layer first as its own PR means the flow announces these moments once and stays otherwise untouched: customer-visible behavior (messages, status changes, handoffs, usage enforcement) remains synchronous, while secondary effects subscribe through listeners. The upcoming conversation outcomes PR then reduces to a listener plus a model instead of another round of edits to the core flow, which is why this ships now, before that work merges. The existing inference reporting behavior is folded into this layer: the `conversation.captain_inference_*` events and their dispatch helpers on `Enterprise::Conversation` are removed, and a dedicated `Captain::ReportingEventListener` (registered on the enterprise async dispatcher) maps `source: 'inference'` events to the same stored reporting event names, so recorded analytics and the assistant stats builder are unaffected. ## What changed - New `Captain::ConversationEvents` facade and event type constants - Event emission from `HookExecutionService` (engagement, usage-limit handoff), `ResponseBuilderJob` (response completed/failed, generation-failure handoff), `HandoffTool` (tool handoff), and `InboxPendingConversationsResolutionJob` (inference resolved/handoff) - A dedicated `Captain::ReportingEventListener` preserves inference reporting events through the new event names, removing captain logic from the OSS listener
106 lines
4.5 KiB
Ruby
106 lines
4.5 KiB
Ruby
class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
|
|
description 'Hand off the conversation to a human agent when unable to assist further'
|
|
param :reason, type: 'string', desc: 'The reason why handoff is needed (optional)', required: false
|
|
|
|
def perform(tool_context, reason: nil)
|
|
conversation = find_conversation(tool_context.state)
|
|
return 'Conversation not found' unless conversation
|
|
|
|
# Log the handoff with reason
|
|
log_tool_usage('tool_handoff', {
|
|
conversation_id: conversation.id,
|
|
reason: reason || 'Agent requested handoff'
|
|
})
|
|
|
|
# Use existing handoff mechanism from ResponseBuilderJob
|
|
handoff_result = trigger_handoff(tool_context, conversation, reason)
|
|
return 'Handoff skipped because a newer customer message arrived' if handoff_result == :stale
|
|
return 'Handoff skipped because the conversation changed' unless handoff_result == :completed
|
|
|
|
"Conversation handed off to human support team#{" (Reason: #{reason})" if reason}"
|
|
rescue StandardError => e
|
|
ChatwootExceptionTracker.new(e).capture_exception
|
|
'Failed to handoff conversation'
|
|
end
|
|
|
|
private
|
|
|
|
def trigger_handoff(tool_context, conversation, reason)
|
|
return trigger_legacy_handoff(tool_context, conversation, reason) unless captain_v2_enabled?
|
|
|
|
note = nil
|
|
handoff_result = conversation.with_lock do
|
|
next :changed unless conversation.pending?
|
|
next :stale if newer_customer_message_arrived?(tool_context.state)
|
|
|
|
# post the reason as a private note
|
|
note = conversation.messages.create!(
|
|
message_type: :outgoing, private: true, sender: @assistant,
|
|
account: conversation.account, inbox: conversation.inbox, content: reason
|
|
)
|
|
|
|
conversation.bot_handoff!(dispatch_event: false)
|
|
:completed
|
|
end
|
|
|
|
return handoff_result unless handoff_result == :completed
|
|
|
|
# Session capture attributes the run to this note so agents can inspect the
|
|
# generation path on the handoff reason instead of the canned follow-up message.
|
|
# A reason-less note has no content and never renders in the dashboard, so
|
|
# leave it unrecorded and let capture fall back to the follow-up message.
|
|
record_handoff_note(tool_context, note) if reason.present?
|
|
|
|
tool_context.state[:captain_v2_handoff_tool_completed] = true
|
|
# Queue the event after the state change commits so notification jobs always see the open conversation.
|
|
conversation.dispatch_bot_handoff_event
|
|
emit_tool_handoff_event(conversation)
|
|
|
|
# Send out of office message if applicable (since template messages were suppressed while Captain was handling)
|
|
send_out_of_office_message_if_applicable(conversation)
|
|
:completed
|
|
end
|
|
|
|
def trigger_legacy_handoff(tool_context, conversation, reason)
|
|
note = conversation.messages.create!(
|
|
message_type: :outgoing, private: true, sender: @assistant,
|
|
account: conversation.account, inbox: conversation.inbox, content: reason
|
|
)
|
|
record_handoff_note(tool_context, note) if reason.present?
|
|
conversation.bot_handoff!
|
|
emit_tool_handoff_event(conversation)
|
|
send_out_of_office_message_if_applicable(conversation)
|
|
:completed
|
|
end
|
|
|
|
def emit_tool_handoff_event(conversation)
|
|
Captain::ConversationEvents.handed_off(conversation: conversation, assistant: @assistant,
|
|
source: Captain::ConversationEvents::Sources::TOOL, at: Time.current)
|
|
end
|
|
|
|
def record_handoff_note(tool_context, note)
|
|
metadata = tool_context.state[:cw_metadata] ||= {}
|
|
metadata[:handoff_note_id] = note.id
|
|
end
|
|
|
|
def send_out_of_office_message_if_applicable(conversation)
|
|
# Campaign conversations should never receive OOO templates — the campaign itself
|
|
# serves as the initial outreach, and OOO would be confusing in that context.
|
|
return if conversation.campaign.present?
|
|
|
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
|
end
|
|
|
|
# TODO: Future enhancement - Add team assignment capability
|
|
# This tool could be enhanced to:
|
|
# 1. Accept team_id parameter for routing to specific teams
|
|
# 2. Set conversation priority based on handoff reason
|
|
# 3. Add metadata for intelligent agent assignment
|
|
# 4. Support escalation levels (L1 -> L2 -> L3)
|
|
#
|
|
# Example future signature:
|
|
# param :team_id, type: 'string', desc: 'ID of team to assign conversation to', required: false
|
|
# param :priority, type: 'string', desc: 'Priority level (low/medium/high/urgent)', required: false
|
|
# param :escalation_level, type: 'string', desc: 'Support level (L1/L2/L3)', required: false
|
|
end
|