feat: add more tools (#12116)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2025-08-08 17:57:30 +05:30
committed by GitHub
parent 4ebfae8b44
commit b5f5c5c1bc
6 changed files with 390 additions and 1 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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