From b5f5c5c1bc130015eafca1566c91af7b95e934f3 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 8 Aug 2025 17:57:30 +0530 Subject: [PATCH] feat: add more tools (#12116) Co-authored-by: Muhsin Keloth --- config/agents/tools.yml | 10 ++ .../lib/captain/tools/faq_lookup_tool.rb | 39 ++++ enterprise/lib/captain/tools/handoff_tool.rb | 52 ++++++ .../lib/captain/tools/update_priority_tool.rb | 4 +- .../lib/captain/tools/faq_lookup_tool_spec.rb | 120 +++++++++++++ .../lib/captain/tools/handoff_tool_spec.rb | 166 ++++++++++++++++++ 6 files changed, 390 insertions(+), 1 deletion(-) create mode 100644 enterprise/lib/captain/tools/faq_lookup_tool.rb create mode 100644 enterprise/lib/captain/tools/handoff_tool.rb create mode 100644 spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb create mode 100644 spec/enterprise/lib/captain/tools/handoff_tool_spec.rb diff --git a/config/agents/tools.yml b/config/agents/tools.yml index b994e93d3..c2faf75e7 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -24,3 +24,13 @@ title: 'Add Label to Conversation' description: 'Add a label to a conversation' icon: 'tag' + +- id: faq_lookup + title: 'FAQ Lookup' + description: 'Search FAQ responses using semantic similarity' + icon: 'search' + +- id: handoff + title: 'Handoff to Human' + description: 'Hand off the conversation to a human agent' + icon: 'user-switch' diff --git a/enterprise/lib/captain/tools/faq_lookup_tool.rb b/enterprise/lib/captain/tools/faq_lookup_tool.rb new file mode 100644 index 000000000..d09b7c2d7 --- /dev/null +++ b/enterprise/lib/captain/tools/faq_lookup_tool.rb @@ -0,0 +1,39 @@ +class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool + description 'Search FAQ responses using semantic similarity to find relevant answers' + param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database' + + def perform(_tool_context, query:) + log_tool_usage('searching', { query: query }) + + # Use existing vector search on approved responses + responses = @assistant.responses.approved.search(query).to_a + + if responses.empty? + log_tool_usage('no_results', { query: query }) + "No relevant FAQs found for: #{query}" + else + log_tool_usage('found_results', { query: query, count: responses.size }) + format_responses(responses) + end + end + + private + + def format_responses(responses) + responses.map { |response| format_response(response) }.join + end + + def format_response(response) + formatted_response = " + Question: #{response.question} + Answer: #{response.answer} + " + if response.documentable.present? && response.documentable.try(:external_link) + formatted_response += " + Source: #{response.documentable.external_link} + " + end + + formatted_response + end +end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb new file mode 100644 index 000000000..49f7c5a65 --- /dev/null +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -0,0 +1,52 @@ +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 + trigger_handoff(conversation, reason) + + "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(conversation, reason) + # post the reason as a private note + conversation.messages.create!( + message_type: :outgoing, + private: true, + sender: @assistant, + account: conversation.account, + inbox: conversation.inbox, + content: reason + ) + + # Trigger the bot handoff (sets status to open + dispatches events) + conversation.bot_handoff! + 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 diff --git a/enterprise/lib/captain/tools/update_priority_tool.rb b/enterprise/lib/captain/tools/update_priority_tool.rb index 8fc75f601..1196911fa 100644 --- a/enterprise/lib/captain/tools/update_priority_tool.rb +++ b/enterprise/lib/captain/tools/update_priority_tool.rb @@ -23,7 +23,9 @@ class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool end def normalize_priority(priority) - priority == 'nil' || priority.blank? ? nil : priority + return nil if priority == 'nil' || priority.blank? + + priority.downcase end def valid_priority?(priority) diff --git a/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb new file mode 100644 index 000000000..ccae44ac2 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/faq_lookup_tool_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::FaqLookupTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:tool_context) { Struct.new(:state).new({}) } + + before do + # Create installation config for OpenAI API key to avoid errors + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + + # Mock embedding service to avoid actual API calls + embedding_service = instance_double(Captain::Llm::EmbeddingService) + allow(Captain::Llm::EmbeddingService).to receive(:new).and_return(embedding_service) + allow(embedding_service).to receive(:get_embedding).and_return(Array.new(1536, 0.1)) + end + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Search FAQ responses using semantic similarity to find relevant answers') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:query) + expect(tool.parameters[:query].name).to eq(:query) + expect(tool.parameters[:query].type).to eq('string') + expect(tool.parameters[:query].description).to eq('The question or topic to search for in the FAQ database') + end + end + + describe '#perform' do + context 'when FAQs exist' do + let(:document) { create(:captain_document, assistant: assistant) } + let!(:response1) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to reset password?', + answer: 'Click on forgot password link', + documentable: document, + status: 'approved') + end + let!(:response2) do + create(:captain_assistant_response, + assistant: assistant, + question: 'How to change email?', + answer: 'Go to settings and update email', + status: 'approved') + end + + before do + # Mock nearest_neighbors to return our test responses + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return( + Captain::AssistantResponse.where(id: [response1.id, response2.id]) + ) + end + + it 'searches FAQs and returns formatted responses' do + result = tool.perform(tool_context, query: 'password reset') + + expect(result).to include('Question: How to reset password?') + expect(result).to include('Answer: Click on forgot password link') + expect(result).to include('Question: How to change email?') + expect(result).to include('Answer: Go to settings and update email') + end + + it 'includes source link when document has external_link' do + document.update!(external_link: 'https://help.example.com/password') + + result = tool.perform(tool_context, query: 'password') + + expect(result).to include('Source: https://help.example.com/password') + end + + it 'logs tool usage for search' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'password reset' }) + expect(tool).to receive(:log_tool_usage).with('found_results', { query: 'password reset', count: 2 }) + + tool.perform(tool_context, query: 'password reset') + end + end + + context 'when no FAQs found' do + before do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + end + + it 'returns no results message' do + result = tool.perform(tool_context, query: 'nonexistent topic') + expect(result).to eq('No relevant FAQs found for: nonexistent topic') + end + + it 'logs tool usage for no results' do + expect(tool).to receive(:log_tool_usage).with('searching', { query: 'nonexistent topic' }) + expect(tool).to receive(:log_tool_usage).with('no_results', { query: 'nonexistent topic' }) + + tool.perform(tool_context, query: 'nonexistent topic') + end + end + + context 'with blank query' do + it 'handles empty query' do + # Return empty result set + allow(Captain::AssistantResponse).to receive(:nearest_neighbors).and_return(Captain::AssistantResponse.none) + + result = tool.perform(tool_context, query: '') + expect(result).to eq('No relevant FAQs found for: ') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb new file mode 100644 index 000000000..16b46c08a --- /dev/null +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::HandoffTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:tool) { described_class.new(assistant) } + let(:user) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + describe '#description' do + it 'returns the correct description' do + expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further') + end + end + + describe '#parameters' do + it 'returns the correct parameters' do + expect(tool.parameters).to have_key(:reason) + expect(tool.parameters[:reason].name).to eq(:reason) + expect(tool.parameters[:reason].type).to eq('string') + expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)') + expect(tool.parameters[:reason].required).to be false + end + end + + describe '#perform' do + context 'when conversation exists' do + context 'with reason provided' do + it 'creates a private note with reason and hands off conversation' do + reason = 'Customer needs specialized support' + + expect do + result = tool.perform(tool_context, reason: reason) + expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})") + end.to change(Message, :count).by(1) + end + + it 'creates message with correct attributes' do + reason = 'Customer needs specialized support' + tool.perform(tool_context, reason: reason) + + created_message = Message.last + expect(created_message.content).to eq(reason) + expect(created_message.message_type).to eq('outgoing') + expect(created_message.private).to be true + expect(created_message.sender).to eq(assistant) + expect(created_message.account).to eq(account) + expect(created_message.inbox).to eq(inbox) + expect(created_message.conversation).to eq(conversation) + end + + it 'triggers bot handoff on conversation' do + # The tool finds the conversation by ID, so we need to mock the found conversation + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + expect(found_conversation).to receive(:bot_handoff!) + + tool.perform(tool_context, reason: 'Test reason') + end + + it 'logs tool usage with reason' do + reason = 'Customer needs help' + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: reason } + ) + + tool.perform(tool_context, reason: reason) + end + end + + context 'without reason provided' do + it 'creates a private note with nil content and hands off conversation' do + expect do + result = tool.perform(tool_context) + expect(result).to eq('Conversation handed off to human support team') + end.to change(Message, :count).by(1) + + created_message = Message.last + expect(created_message.content).to be_nil + end + + it 'logs tool usage with default reason' do + expect(tool).to receive(:log_tool_usage).with( + 'tool_handoff', + { conversation_id: conversation.id, reason: 'Agent requested handoff' } + ) + + tool.perform(tool_context) + end + end + + context 'when handoff fails' do + before do + # Mock the conversation lookup and handoff failure + found_conversation = Conversation.find(conversation.id) + scoped_conversations = Conversation.where(account_id: assistant.account_id) + allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) + allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) + allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error') + + exception_tracker = instance_double(ChatwootExceptionTracker) + allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + end + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Failed to handoff conversation') + end + + it 'captures exception' do + exception_tracker = instance_double(ChatwootExceptionTracker) + expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker) + expect(exception_tracker).to receive(:capture_exception) + + tool.perform(tool_context, reason: 'Test') + end + end + end + + context 'when conversation does not exist' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + + it 'does not create a message' do + expect do + tool.perform(tool_context, reason: 'Test') + end.not_to change(Message, :count) + end + end + + context 'when conversation state is missing' do + let(:tool_context) { Struct.new(:state).new({}) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + + context 'when conversation id is nil' do + let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } + + it 'returns error message' do + result = tool.perform(tool_context, reason: 'Test') + expect(result).to eq('Conversation not found') + end + end + end + + describe '#active?' do + it 'returns true for public tools' do + expect(tool.active?).to be true + end + end +end