Files
moreminimore-chat/enterprise/lib/captain/tools/handoff_tool.rb
Shivam Mishra 7981e2cc75 refactor: introduce normalized Captain lifecycle events [CW-7792] (#15213)
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
2026-07-31 13:55:57 +05:30

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