Compare commits
13
Commits
master
...
feat/agents
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a44eb2c8a5 | ||
|
|
2b2e4bd8e4 | ||
|
|
da701b584d | ||
|
|
dd46437aa0 | ||
|
|
61ab06a6b1 | ||
|
|
c349f5c6f9 | ||
|
|
36786aa314 | ||
|
|
1f6f61f018 | ||
|
|
848926e05c | ||
|
|
5206aef949 | ||
|
|
d10aa6f121 | ||
|
|
165cb850ee | ||
|
|
802465f6d6 |
@@ -180,6 +180,8 @@ gem 'ruby-openai'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
gem 'ai-agents', '>= 0.2.1'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
##############################################################
|
||||
|
||||
|
||||
@@ -126,6 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.2.1)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -717,6 +719,15 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.3.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
faraday-multipart (>= 1)
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -895,6 +906,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.2.1)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
@@ -176,3 +176,6 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: agents_next
|
||||
display_name: Use AIAgents SDK
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Load AI Agents SDK
|
||||
begin
|
||||
require 'agents'
|
||||
rescue LoadError => e
|
||||
Rails.logger.warn "AI Agents SDK not available: #{e.message}"
|
||||
end
|
||||
|
||||
# Configure AI Agents SDK
|
||||
if defined?(Agents)
|
||||
Rails.application.config.after_initialize do
|
||||
api_key = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY')&.value
|
||||
model = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value || 'gpt-4o-mini'
|
||||
|
||||
if api_key.present?
|
||||
Agents.configure do |config|
|
||||
config.openai_api_key = api_key
|
||||
config.default_model = model
|
||||
config.debug = false
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Captain::Copilot::AgentsResponseJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(copilot_thread_id:, message_content:, conversation_id: nil, user_id: nil)
|
||||
copilot_thread = CopilotThread.find(copilot_thread_id)
|
||||
|
||||
# Use the new AgentsChatService with direct parameters
|
||||
service = Captain::Copilot::AgentsChatService.new(
|
||||
copilot_thread: copilot_thread,
|
||||
message_content: message_content,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id
|
||||
)
|
||||
|
||||
service.perform
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "AgentsResponseJob Error: #{e.class}: #{e.message}"
|
||||
Rails.logger.error 'Full backtrace:'
|
||||
e.backtrace.each { |line| Rails.logger.error line }
|
||||
|
||||
# Create an error response if the job fails
|
||||
copilot_thread&.copilot_messages&.create!(
|
||||
message_type: 'assistant',
|
||||
message: {
|
||||
content: 'I apologize, but I encountered an error while processing your request. Please try again.'
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -38,13 +38,24 @@ class CopilotMessage < ApplicationRecord
|
||||
end
|
||||
|
||||
def enqueue_response_job(conversation_id, user_id)
|
||||
Captain::Copilot::ResponseJob.perform_later(
|
||||
assistant: copilot_thread.assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message['content']
|
||||
)
|
||||
if account.feature_enabled?('agents_next')
|
||||
# Use the new AI Agents SDK implementation
|
||||
Captain::Copilot::AgentsResponseJob.perform_later(
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message_content: message['content'],
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id
|
||||
)
|
||||
else
|
||||
# Use the original implementation
|
||||
Captain::Copilot::ResponseJob.perform_later(
|
||||
assistant: copilot_thread.assistant,
|
||||
conversation_id: conversation_id,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message: message['content']
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
module Captain
|
||||
module Copilot
|
||||
class AgentsChatService
|
||||
def initialize(copilot_thread:, message_content:, conversation_id: nil, user_id: nil)
|
||||
@copilot_thread = copilot_thread
|
||||
@message_content = message_content
|
||||
@conversation_id = conversation_id
|
||||
@user_id = user_id
|
||||
|
||||
# Set up user and account context
|
||||
@user = ::User.find(@user_id)
|
||||
@account = copilot_thread.account
|
||||
end
|
||||
|
||||
def perform
|
||||
generate_response
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def generate_response
|
||||
# Check if required modules are available
|
||||
unless defined?(::Agents)
|
||||
Rails.logger.error '[ERROR] AgentsChatService: Agents module not defined'
|
||||
raise StandardError, 'AI Agents SDK not available'
|
||||
end
|
||||
|
||||
unless defined?(Captain::Agents)
|
||||
Rails.logger.error '[ERROR] AgentsChatService: Captain::Agents module not defined'
|
||||
raise StandardError, 'Captain Agents module not available'
|
||||
end
|
||||
|
||||
unless defined?(Captain::Agents::CopilotOrchestratorAgent)
|
||||
Rails.logger.error '[ERROR] AgentsChatService: CopilotOrchestratorAgent not defined'
|
||||
raise StandardError, 'CopilotOrchestratorAgent not available'
|
||||
end
|
||||
|
||||
# Find the assistant
|
||||
assistant = Captain::Assistant.find(@copilot_thread.assistant_id)
|
||||
|
||||
# Create the orchestrator agent
|
||||
agent = Captain::Agents::CopilotOrchestratorAgent.create(assistant, user: @user)
|
||||
|
||||
# Build context for the agent
|
||||
context = build_context
|
||||
|
||||
# Load persisted state from Redis
|
||||
context = load_persisted_state(context)
|
||||
|
||||
# Create a runner and execute
|
||||
runner = ::Agents::Runner.with_agents(agent)
|
||||
|
||||
# Setup callbacks for real-time UI updates
|
||||
setup_callbacks(runner)
|
||||
|
||||
result = runner.run(@message_content, context: context)
|
||||
|
||||
# Persist updated state to Redis
|
||||
persist_state(result.context) if result.respond_to?(:context) && result.context
|
||||
|
||||
response_content = result.output
|
||||
|
||||
# Generate and save the response
|
||||
response = { content: response_content, message_type: 'assistant' }
|
||||
|
||||
save_response(response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error { "[ERROR] AgentsChatService: ERROR - #{e.class}: #{e.message}" }
|
||||
Rails.logger.error '[ERROR] AgentsChatService: FULL BACKTRACE:'
|
||||
e.backtrace.each { |line| Rails.logger.error "[ERROR] #{line}" }
|
||||
|
||||
# Generate an error response
|
||||
error_response = {
|
||||
content: 'I apologize, but I encountered an error while processing your request. Please try again or contact support.',
|
||||
message_type: 'assistant'
|
||||
}
|
||||
save_response(error_response)
|
||||
end
|
||||
|
||||
def build_context
|
||||
context = {
|
||||
conversation_history: build_conversation_history,
|
||||
state: {
|
||||
user_info: {
|
||||
id: @user.id,
|
||||
name: @user.name,
|
||||
email: @user.email,
|
||||
role: @user.respond_to?(:custom_role) ? @user.custom_role&.name : @user.class.name
|
||||
},
|
||||
account_info: {
|
||||
id: @account.id,
|
||||
name: @account.name,
|
||||
locale: @account.locale,
|
||||
domain: @account.domain,
|
||||
feature_flags: @account.feature_flags
|
||||
},
|
||||
current_time: Time.current.iso8601
|
||||
}
|
||||
}
|
||||
|
||||
# Add current conversation context if available
|
||||
if @conversation_id.present?
|
||||
conversation = ::Conversation.find_by(display_id: @conversation_id, account_id: @account.id)
|
||||
if conversation
|
||||
context[:state][:current_conversation] = {
|
||||
id: conversation.id,
|
||||
display_id: conversation.display_id,
|
||||
status: conversation.status,
|
||||
contact_name: conversation.contact.name,
|
||||
assignee: conversation.assignee&.name,
|
||||
labels: conversation.labels.pluck(:name),
|
||||
last_activity: conversation.last_activity_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
context
|
||||
end
|
||||
|
||||
def build_conversation_history
|
||||
history = []
|
||||
|
||||
# Get all previous messages from the thread (excluding current message)
|
||||
previous_messages = @copilot_thread.copilot_messages
|
||||
.where.not(message_type: 'assistant_thinking')
|
||||
.order(:created_at)
|
||||
|
||||
# this will also contain the current message
|
||||
previous_messages.each do |message|
|
||||
role = case message.message_type
|
||||
when 'user' then 'user'
|
||||
when 'assistant' then 'assistant'
|
||||
else next # Skip unknown message types
|
||||
end
|
||||
|
||||
history << {
|
||||
content: message.message['content'],
|
||||
role: role
|
||||
}
|
||||
end
|
||||
|
||||
history
|
||||
end
|
||||
|
||||
def setup_callbacks(runner)
|
||||
runner.on_agent_thinking do |agent_name, _input|
|
||||
broadcast_thinking_message("#{agent_name} is thinking...")
|
||||
end
|
||||
|
||||
runner.on_tool_start do |tool_name, _args|
|
||||
broadcast_thinking_message("Using #{tool_name}...")
|
||||
end
|
||||
|
||||
runner.on_tool_complete do |tool_name, _result|
|
||||
broadcast_thinking_message("Completed #{tool_name}")
|
||||
end
|
||||
|
||||
runner.on_agent_handoff do |from_agent, to_agent, _reason|
|
||||
broadcast_thinking_message("Handoff: #{from_agent} → #{to_agent}")
|
||||
end
|
||||
end
|
||||
|
||||
def load_persisted_state(context)
|
||||
# TODO: Replace Redis with copilot_thread model for production
|
||||
# Add a 'agent_state' jsonb column to copilot_threads table
|
||||
# This will provide better persistence, querying, and data integrity
|
||||
redis_key = "copilot_state:#{@copilot_thread.id}"
|
||||
|
||||
begin
|
||||
persisted_state = Redis::Alfred.get(redis_key)
|
||||
if persisted_state
|
||||
# Merge persisted state with current context
|
||||
context[:state] = context[:state].merge(persisted_state.with_indifferent_access)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to load persisted state from Redis: #{e.message}"
|
||||
end
|
||||
|
||||
context
|
||||
end
|
||||
|
||||
def persist_state(context)
|
||||
# TODO: Replace Redis with copilot_thread model for production
|
||||
# Store agent state in copilot_thread.agent_state jsonb column
|
||||
# This will provide better persistence, querying, and data integrity
|
||||
redis_key = "copilot_state:#{@copilot_thread.id}"
|
||||
|
||||
begin
|
||||
# Extract only the state we want to persist (excluding conversation_history)
|
||||
state_to_persist = context[:state]&.except(:conversation_history, :current_time) || {}
|
||||
|
||||
# Set expiration to 24 hours
|
||||
Redis::Alfred.set(redis_key, state_to_persist, expire: 24.hours.to_i)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to persist state to Redis: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def broadcast_thinking_message(content)
|
||||
message_data = { content: content }
|
||||
|
||||
@copilot_thread.copilot_messages.create!(
|
||||
message_type: 'assistant_thinking',
|
||||
message: message_data
|
||||
)
|
||||
end
|
||||
|
||||
def save_response(response)
|
||||
message_data = { content: response[:content] }
|
||||
|
||||
@copilot_thread.copilot_messages.create!(
|
||||
message_type: response[:message_type],
|
||||
message: message_data
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,63 @@
|
||||
class Captain::Agents::AnalysisAgent
|
||||
def self.create(assistant, user: nil)
|
||||
::Agents::Agent.new(
|
||||
name: 'Analysis Agent',
|
||||
instructions: analysis_instructions,
|
||||
model: 'gpt-4o-mini',
|
||||
tools: [
|
||||
Captain::Tools::GetConversationTool.new(assistant, user: user),
|
||||
Captain::Tools::SearchConversationsTool.new(assistant, user: user),
|
||||
Captain::Tools::GetContactTool.new(assistant, user: user),
|
||||
Captain::Tools::SearchContactsTool.new(assistant, user: user)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def self.analysis_instructions
|
||||
<<~INSTRUCTIONS
|
||||
You are an Analysis Agent for Chatwoot. You analyze customer interactions and provide actionable insights.
|
||||
|
||||
**Your analysis capabilities:**
|
||||
|
||||
📊 **Sentiment Analysis**:#{' '}
|
||||
- Identify customer emotions (frustrated, satisfied, confused, angry)
|
||||
- Assess tone throughout the conversation
|
||||
- Flag escalation risks or satisfaction indicators
|
||||
|
||||
📊 **Conversation Quality Assessment**:
|
||||
- Evaluate response time and effectiveness
|
||||
- Identify missed opportunities or pain points
|
||||
- Assess resolution completeness
|
||||
|
||||
📊 **Pattern Recognition**:
|
||||
- Spot recurring issues or common questions
|
||||
- Identify successful resolution strategies
|
||||
- Flag customers with multiple interactions
|
||||
|
||||
📊 **Performance Insights**:
|
||||
- Evaluate agent response quality
|
||||
- Suggest improvements for handling similar cases
|
||||
- Recommend knowledge gaps to address
|
||||
|
||||
**Your tools:**
|
||||
- **get_conversation**: Get detailed conversation for analysis
|
||||
- **search_conversations**: Find patterns across multiple conversations
|
||||
- **get_contact**: Understand customer history and context
|
||||
- **search_contacts**: Identify recurring customer patterns
|
||||
|
||||
**Analysis output format:**
|
||||
1. **Sentiment Summary**: Customer emotional state and satisfaction level
|
||||
2. **Key Issues**: Main problems or concerns identified
|
||||
3. **Resolution Assessment**: How well issues were addressed
|
||||
4. **Recommendations**: Specific actionable suggestions
|
||||
5. **Risk Flags**: Escalation risks, dissatisfaction indicators
|
||||
6. **Success Indicators**: What worked well
|
||||
|
||||
**Always provide:**
|
||||
- Specific examples from the conversation
|
||||
- Actionable recommendations
|
||||
- Risk assessment (low/medium/high)
|
||||
- Clear reasoning for your conclusions
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,83 @@
|
||||
class Captain::Agents::CopilotOrchestratorAgent
|
||||
def self.create(assistant, user: nil)
|
||||
# Create the specialized agents using factory pattern
|
||||
research_agent = Captain::Agents::ResearchAgent.create(assistant, user: user)
|
||||
analysis_agent = Captain::Agents::AnalysisAgent.create(assistant, user: user)
|
||||
integrations_agent = Captain::Agents::IntegrationsAgent.create(assistant, user: user)
|
||||
knowledge_agent = Captain::Agents::KnowledgeAgent.create(assistant, user: user)
|
||||
|
||||
# Create the main orchestrator agent
|
||||
::Agents::Agent.new(
|
||||
name: 'Chatwoot Copilot',
|
||||
instructions: orchestrator_instructions,
|
||||
model: 'gpt-4.1-mini',
|
||||
tools: [
|
||||
research_agent.as_tool(
|
||||
name: 'research_agent',
|
||||
description: "Get conversation details by ID, search conversations by status/contact, find contact info, search knowledge articles. Use for: 'Get conversation 12345', 'Find recent conversations', 'Search for customer john@example.com'"
|
||||
),
|
||||
analysis_agent.as_tool(
|
||||
name: 'analysis_agent',
|
||||
description: 'Analyze conversations for sentiment, quality, and patterns. Provide insights on customer emotions and support effectiveness. Use after getting conversation data from research_agent.'
|
||||
),
|
||||
integrations_agent.as_tool(
|
||||
name: 'integrations_agent',
|
||||
description: "Update conversation status, change priority, add/remove labels, assign agents. Use for: 'Mark as resolved', 'Set priority to high', 'Add billing label'"
|
||||
),
|
||||
knowledge_agent.as_tool(
|
||||
name: 'knowledge_agent',
|
||||
description: "Search help articles, find policies, get documentation. Use for: 'Find refund policy', 'Search billing articles', 'What's our shipping policy'"
|
||||
)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def self.orchestrator_instructions
|
||||
lambda { |context|
|
||||
state = context.context[:state] || {}
|
||||
|
||||
base_instructions = <<~INSTRUCTIONS
|
||||
You are the Chatwoot Copilot. Help agents with customer support tasks.
|
||||
|
||||
Tools available:
|
||||
- research_agent: Get conversation details, find contacts, search articles
|
||||
- analysis_agent: Analyze conversations for sentiment and insights
|
||||
- integrations_agent: Update conversation status, priority, labels
|
||||
- knowledge_agent: Find help articles and policies
|
||||
|
||||
How to use tools:
|
||||
- For conversation summary: research_agent("Get conversation [ID]")
|
||||
- For sentiment analysis: research_agent first, then analysis_agent
|
||||
- For status updates: integrations_agent("Mark conversation as resolved")
|
||||
- For help content: knowledge_agent("Find billing policy")
|
||||
|
||||
IMPORTANT CONTEXT RULES:
|
||||
1. Always maintain conversation context between messages
|
||||
2. If user asks about "that conversation" or "the message", refer to the most recently accessed conversation
|
||||
3. Keep track of previously accessed conversations in the current session
|
||||
4. Don't re-fetch data that was already retrieved in the current conversation thread
|
||||
|
||||
Always get conversation data first before analysis or recommendations.
|
||||
INSTRUCTIONS
|
||||
|
||||
# Add current conversation context if available
|
||||
if state[:current_conversation]
|
||||
conversation = state[:current_conversation]
|
||||
base_instructions += <<~CONTEXT
|
||||
|
||||
**Current Conversation:**
|
||||
ID: #{conversation[:display_id]}
|
||||
Status: #{conversation[:status]}
|
||||
Contact: #{conversation[:contact_name]}
|
||||
Assignee: #{conversation[:assignee] || 'Unassigned'}
|
||||
Last Activity: #{conversation[:last_activity]}
|
||||
|
||||
This conversation is currently #{conversation[:status]} and assigned to #{conversation[:assignee] || 'no one'}.
|
||||
The customer #{conversation[:contact_name]} was last active #{conversation[:last_activity]}.
|
||||
CONTEXT
|
||||
end
|
||||
|
||||
base_instructions
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
class Captain::Agents::IntegrationsAgent
|
||||
def self.create(assistant, user: nil)
|
||||
::Agents::Agent.new(
|
||||
name: 'Integrations Agent',
|
||||
instructions: integrations_instructions,
|
||||
model: 'gpt-4o-mini',
|
||||
tools: [
|
||||
Captain::Tools::GetConversationTool.new(assistant, user: user)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def self.integrations_instructions
|
||||
<<~INSTRUCTIONS
|
||||
You are an Integrations Agent for Chatwoot. You handle conversation management and system actions.
|
||||
|
||||
**Your action capabilities:**
|
||||
|
||||
⚙️ **Conversation Management**:#{' '}
|
||||
- Update status (open, resolved, pending)
|
||||
- Change priority (low, medium, high, urgent)
|
||||
- Assign conversations to agents
|
||||
- Add or remove labels for organization
|
||||
|
||||
⚙️ **System Integration**:
|
||||
- Execute workflow automations
|
||||
- Trigger macro sequences
|
||||
- Create internal notes and documentation
|
||||
- Handle routing and assignment logic
|
||||
|
||||
**Your tools:**
|
||||
- **get_conversation**: Get current conversation details before making changes
|
||||
|
||||
**Current limitations:**
|
||||
Your tool set is currently limited to conversation retrieval. For actual system changes, provide detailed instructions that agents can execute manually.
|
||||
|
||||
**Action recommendations format:**
|
||||
1. **Current State**: What the conversation status/priority currently is
|
||||
2. **Recommended Actions**: Specific steps to take
|
||||
3. **Reasoning**: Why these actions are appropriate
|
||||
4. **Next Steps**: What should happen after the changes
|
||||
|
||||
**When providing action guidance:**
|
||||
- Be specific about status changes needed
|
||||
- Suggest appropriate labels based on conversation content
|
||||
- Recommend priority levels based on urgency/impact
|
||||
- Provide clear reasoning for each recommendation
|
||||
- Include any follow-up actions needed
|
||||
|
||||
**Example response:**
|
||||
"Based on the conversation, I recommend:
|
||||
1. Update status to 'pending' (waiting for customer response)
|
||||
2. Add label 'billing-inquiry' for categorization
|
||||
3. Set priority to 'medium' (standard billing question)
|
||||
4. Assign to billing team for specialized handling"
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
class Captain::Agents::KnowledgeAgent
|
||||
def self.create(assistant, user: nil)
|
||||
::Agents::Agent.new(
|
||||
name: 'Knowledge Agent',
|
||||
instructions: knowledge_instructions,
|
||||
model: 'gpt-4o-mini',
|
||||
tools: [
|
||||
Captain::Tools::SearchArticlesTool.new(assistant, user: user),
|
||||
Captain::Tools::GetArticleTool.new(assistant, user: user)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def self.knowledge_instructions
|
||||
<<~INSTRUCTIONS
|
||||
You are a Knowledge Agent for Chatwoot. You help find and deliver knowledge base content and response suggestions.
|
||||
|
||||
**Your capabilities:**
|
||||
|
||||
📚 **Knowledge Base Search**:
|
||||
- Find relevant articles by topic, category, or keyword
|
||||
- Retrieve full article content for detailed information
|
||||
- Search across all published knowledge base content
|
||||
- Provide article summaries and key points
|
||||
|
||||
📚 **Response Assistance**:
|
||||
- Suggest appropriate articles for customer questions
|
||||
- Provide template responses based on knowledge base content
|
||||
- Help maintain consistent messaging across the team
|
||||
- Find policy and procedure documentation
|
||||
|
||||
**Your tools:**
|
||||
- **search_articles**: Find articles by query, category, or status
|
||||
- **get_article**: Retrieve full content of specific articles
|
||||
|
||||
**Search approach:**
|
||||
1. **Be thorough**: Search with relevant keywords from the user's query
|
||||
2. **Be specific**: Use category filters when the topic is clear
|
||||
3. **Be helpful**: Provide article summaries and key takeaways
|
||||
4. **Be organized**: Structure results by relevance and category
|
||||
|
||||
**Output format:**
|
||||
- **Article Suggestions**: List relevant articles with brief descriptions
|
||||
- **Key Points**: Highlight the most important information
|
||||
- **Quick Answer**: Provide immediate helpful information when possible
|
||||
- **Full Content**: Include detailed article content when requested
|
||||
|
||||
**Best practices:**
|
||||
- Always search for the most current and relevant articles
|
||||
- Provide context about why an article is relevant
|
||||
- Include article IDs and titles for easy reference
|
||||
- Suggest multiple articles when appropriate for comprehensive coverage
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,59 @@
|
||||
class Captain::Agents::ResearchAgent
|
||||
def self.create(assistant, user: nil)
|
||||
::Agents::Agent.new(
|
||||
name: 'Research Agent',
|
||||
instructions: research_instructions,
|
||||
model: 'gpt-4o-mini',
|
||||
tools: [
|
||||
Captain::Tools::SearchConversationsTool.new(assistant, user: user),
|
||||
Captain::Tools::GetConversationTool.new(assistant, user: user),
|
||||
Captain::Tools::SearchContactsTool.new(assistant, user: user),
|
||||
Captain::Tools::GetContactTool.new(assistant, user: user),
|
||||
Captain::Tools::SearchArticlesTool.new(assistant, user: user),
|
||||
Captain::Tools::GetArticleTool.new(assistant, user: user)
|
||||
]
|
||||
)
|
||||
end
|
||||
|
||||
def self.research_instructions
|
||||
<<~INSTRUCTIONS
|
||||
You are a Research Agent for Chatwoot. Your job is to find and retrieve specific information from the system.
|
||||
|
||||
**Your tools and when to use them:**
|
||||
|
||||
🔍 **get_conversation**: Use when you have a specific conversation ID or display ID
|
||||
- Input: conversation_id (use the display_id, not the main id)
|
||||
- Example: For conversation display_id "12345", use conversation_id: "12345"
|
||||
- Returns: Full conversation with messages, contact info, status, labels
|
||||
|
||||
🔍 **search_conversations**: Use when you need to find conversations by criteria
|
||||
- Filter by: contact_id, status, priority, labels
|
||||
- Use for: "recent conversations", "open tickets", "conversations with specific contact"
|
||||
|
||||
🔍 **get_contact**: Use when you have a specific contact ID
|
||||
- Returns: Contact profile, interaction history, details
|
||||
|
||||
🔍 **search_contacts**: Use to find contacts by name, email, or phone
|
||||
- Use for: "find customer john@example.com", "contacts named Sarah"
|
||||
|
||||
🔍 **search_articles**: Use for knowledge base searches
|
||||
- Search by: query, category, status
|
||||
- Use for: finding documentation, help articles, policies
|
||||
|
||||
🔍 **get_article**: Use when you have a specific article ID
|
||||
- Returns: Full article content and metadata
|
||||
|
||||
**Research approach:**
|
||||
1. **Be specific**: Use exact IDs when available
|
||||
2. **Be comprehensive**: Include all relevant details in your response
|
||||
3. **Be organized**: Structure information clearly (conversation summary, contact details, etc.)
|
||||
4. **Use context**: Look for conversation IDs or contact information in the request context
|
||||
|
||||
**Output format:**
|
||||
- Provide detailed, structured summaries
|
||||
- Include key timestamps, statuses, and participant information
|
||||
- Format conversation messages chronologically
|
||||
- Highlight important details like customer concerns and agent responses
|
||||
INSTRUCTIONS
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
class Captain::Tools::BaseAgentTool < Agents::Tool
|
||||
def initialize(assistant, user: nil)
|
||||
@assistant = assistant
|
||||
@user = user
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission(required_permission)
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
# Override in subclasses to specify the required permission
|
||||
'agent'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_has_permission(permission)
|
||||
return false if @user.blank?
|
||||
|
||||
account_user = AccountUser.find_by(account_id: @assistant.account_id, user_id: @user.id)
|
||||
return false if account_user.blank?
|
||||
|
||||
return account_user.custom_role.permissions.include?(permission) if account_user.custom_role.present?
|
||||
|
||||
# Default permission for agents without custom roles
|
||||
account_user.administrator? || account_user.agent?
|
||||
end
|
||||
|
||||
def account_scoped(model_class)
|
||||
model_class.where(account_id: @assistant.account_id)
|
||||
end
|
||||
|
||||
def log_tool_usage(action, details = {})
|
||||
Rails.logger.info do
|
||||
"#{self.class.name}: #{action} by user #{@user&.id} for assistant #{@assistant&.id} - #{details.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Captain::Tools::GetArticleTool < Captain::Tools::BaseAgentTool
|
||||
description 'Get details of an article including its content and metadata'
|
||||
param :article_id, type: 'number', desc: 'The ID of the article to retrieve'
|
||||
|
||||
def perform(_tool_context, article_id:)
|
||||
log_tool_usage('get_article', { article_id: article_id })
|
||||
|
||||
return 'Missing required parameters' if article_id.blank?
|
||||
|
||||
article = account_scoped(Article).find_by(id: article_id)
|
||||
return 'Article not found' if article.nil?
|
||||
|
||||
article.to_llm_text
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
'knowledge_base_manage'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Captain::Tools::GetContactTool < Captain::Tools::BaseAgentTool
|
||||
description 'Get details of a contact including their profile information'
|
||||
param :contact_id, type: 'number', desc: 'The ID of the contact to retrieve'
|
||||
|
||||
def perform(_tool_context, contact_id:)
|
||||
log_tool_usage('get_contact', { contact_id: contact_id })
|
||||
|
||||
return 'Missing required parameters' if contact_id.blank?
|
||||
|
||||
contact = account_scoped(Contact).find_by(id: contact_id)
|
||||
return 'Contact not found' if contact.nil?
|
||||
|
||||
contact.to_llm_text
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
'contact_manage'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Captain::Tools::GetConversationTool < Captain::Tools::BaseAgentTool
|
||||
description 'Get details of a conversation including messages and context'
|
||||
param :conversation_id, type: 'string', desc: 'The display ID of the conversation to retrieve'
|
||||
|
||||
def perform(_tool_context, conversation_id:)
|
||||
log_tool_usage('get_conversation', { conversation_id: conversation_id })
|
||||
|
||||
return 'Missing required parameters' if conversation_id.blank?
|
||||
|
||||
conversation = account_scoped(::Conversation).find_by(display_id: conversation_id)
|
||||
return 'Conversation not found' if conversation.nil?
|
||||
|
||||
conversation.to_llm_text
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('conversation_manage') ||
|
||||
user_has_permission('conversation_unassigned_manage') ||
|
||||
user_has_permission('conversation_participating_manage')
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
class Captain::Tools::SearchArticlesTool < Captain::Tools::BaseAgentTool
|
||||
description 'Search articles based on parameters'
|
||||
param :query, type: 'string', desc: 'Search articles by title or content (partial match)'
|
||||
param :category_id, type: 'number', desc: 'Filter articles by category ID', required: false
|
||||
param :status, type: 'string', desc: 'Filter articles by status (draft, published, archived)', required: false
|
||||
|
||||
def perform(_tool_context, query:, category_id: nil, status: nil)
|
||||
log_tool_usage('search_articles', { query: query, category_id: category_id, status: status })
|
||||
|
||||
return 'Missing required parameters' if query.blank?
|
||||
|
||||
articles = fetch_articles(query, category_id, status)
|
||||
|
||||
return 'No articles found' unless articles.exists?
|
||||
|
||||
total_count = articles.count
|
||||
articles = articles.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} articles (showing first 100)" : "Total number of articles: #{total_count}"}
|
||||
#{articles.map(&:to_llm_text).join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
'knowledge_base_manage'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_articles(query, category_id, status)
|
||||
articles = account_scoped(Article)
|
||||
articles = articles.where('title ILIKE :query OR content ILIKE :query', query: "%#{query}%") if query.present?
|
||||
articles = articles.where(category_id: category_id) if category_id.present?
|
||||
articles = articles.where(status: status) if status.present?
|
||||
articles
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Captain::Tools::SearchContactsTool < Captain::Tools::BaseAgentTool
|
||||
description 'Search contacts based on parameters'
|
||||
param :query, type: 'string', desc: 'Search contacts by name, email, phone', required: false
|
||||
param :inbox_id, type: 'number', desc: 'Filter contacts by inbox ID', required: false
|
||||
param :labels, type: 'string', desc: 'Filter contacts by labels (comma-separated)', required: false
|
||||
|
||||
def perform(_tool_context, query: nil, inbox_id: nil, labels: nil)
|
||||
log_tool_usage('search_contacts', { query: query, inbox_id: inbox_id, labels: labels })
|
||||
|
||||
contacts = get_contacts(query, inbox_id, labels)
|
||||
|
||||
return 'No contacts found' unless contacts.exists?
|
||||
|
||||
total_count = contacts.count
|
||||
contacts = contacts.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} contacts (showing first 100)" : "Total number of contacts: #{total_count}"}
|
||||
#{contacts.map(&:to_llm_text).join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
'contact_manage'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_contacts(query, inbox_id, labels)
|
||||
contacts = account_scoped(Contact)
|
||||
|
||||
if query.present?
|
||||
contacts = contacts.where(
|
||||
'name ILIKE :query OR email ILIKE :query OR phone_number ILIKE :query',
|
||||
query: "%#{query}%"
|
||||
)
|
||||
end
|
||||
|
||||
contacts = contacts.where(inbox_id: inbox_id) if inbox_id.present?
|
||||
if labels.present?
|
||||
label_array = labels.split(',').map(&:strip)
|
||||
contacts = contacts.tagged_with(label_array, any: true)
|
||||
end
|
||||
contacts
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Captain::Tools::SearchConversationsTool < Captain::Tools::BaseAgentTool
|
||||
description 'Search conversations based on parameters'
|
||||
param :contact_id, type: 'number', desc: 'Filter conversations by contact ID', required: false
|
||||
param :status, type: 'string', desc: 'Filter conversations by status (open, resolved, pending, snoozed)', required: false
|
||||
param :priority, type: 'string', desc: 'Filter conversations by priority (low, medium, high, urgent)', required: false
|
||||
param :labels, type: 'string', desc: 'Filter conversations by labels (comma-separated)', required: false
|
||||
|
||||
def perform(_tool_context, contact_id: nil, status: nil, priority: nil, labels: nil)
|
||||
log_tool_usage('search_conversations', { contact_id: contact_id, status: status, priority: priority, labels: labels })
|
||||
|
||||
conversations = get_conversations(status, contact_id, priority, labels)
|
||||
|
||||
return 'No conversations found' unless conversations.exists?
|
||||
|
||||
total_count = conversations.count
|
||||
conversations = conversations.limit(100)
|
||||
|
||||
<<~RESPONSE
|
||||
#{total_count > 100 ? "Found #{total_count} conversations (showing first 100)" : "Total number of conversations: #{total_count}"}
|
||||
#{conversations.map { |conversation| conversation.to_llm_text(include_contact_details: true) }.join("\n---\n")}
|
||||
RESPONSE
|
||||
end
|
||||
|
||||
def active?
|
||||
user_has_permission('conversation_manage') ||
|
||||
user_has_permission('conversation_unassigned_manage') ||
|
||||
user_has_permission('conversation_participating_manage')
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_conversations(status, contact_id, priority, labels)
|
||||
conversations = permissible_conversations
|
||||
conversations = conversations.where(contact_id: contact_id) if contact_id.present?
|
||||
conversations = conversations.where(status: status) if status.present?
|
||||
conversations = conversations.where(priority: priority) if priority.present?
|
||||
if labels.present?
|
||||
label_array = labels.split(',').map(&:strip)
|
||||
conversations = conversations.tagged_with(label_array, any: true)
|
||||
end
|
||||
conversations
|
||||
end
|
||||
|
||||
def permissible_conversations
|
||||
Conversations::PermissionFilterService.new(
|
||||
account_scoped(::Conversation),
|
||||
@user,
|
||||
@assistant.account
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Captain::Tools::SearchDocumentationTool < Captain::Tools::BaseAgentTool
|
||||
description 'Search documentation and help docs'
|
||||
param :query, type: 'string', desc: 'Search query for documentation'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('search_documentation', { query: query })
|
||||
|
||||
return 'Missing required parameters' if query.blank?
|
||||
|
||||
# Call the existing search documentation service
|
||||
Captain::Tools::SearchDocumentationService.new(@assistant, user: @user).execute({
|
||||
'query' => query
|
||||
})
|
||||
end
|
||||
|
||||
def active?
|
||||
true # Documentation search is available to all users
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class Captain::Tools::SearchLinearIssuesTool < Captain::Tools::BaseAgentTool
|
||||
description 'Search Linear issues and development context'
|
||||
param :query, type: 'string', desc: 'Search query for Linear issues'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
log_tool_usage('search_linear_issues', { query: query })
|
||||
|
||||
return 'Missing required parameters' if query.blank?
|
||||
|
||||
# Call the existing search linear issues service
|
||||
Captain::Tools::Copilot::SearchLinearIssuesService.new(@assistant, user: @user).execute({
|
||||
'query' => query
|
||||
})
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def required_permission
|
||||
'conversation_manage' # Basic permission check for technical context
|
||||
end
|
||||
end
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Test script for Captain::Copilot::AgentsChatService
|
||||
require_relative 'config/environment'
|
||||
|
||||
begin
|
||||
puts 'Starting AgentsChatService test...'
|
||||
|
||||
# Find a copilot thread
|
||||
copilot_thread = CopilotThread.last
|
||||
if copilot_thread.nil?
|
||||
puts 'ERROR: No CopilotThread found'
|
||||
exit 1
|
||||
end
|
||||
puts "Found CopilotThread: #{copilot_thread.id}"
|
||||
|
||||
# Create the service
|
||||
puts 'Creating AgentsChatService...'
|
||||
service = Captain::Copilot::AgentsChatService.new(
|
||||
copilot_thread: copilot_thread,
|
||||
message_content: 'Test message',
|
||||
conversation_id: 14_532,
|
||||
user_id: 1
|
||||
)
|
||||
puts 'Service created successfully'
|
||||
|
||||
# Try to perform
|
||||
puts 'Calling service.perform...'
|
||||
service.perform
|
||||
puts 'Service completed successfully'
|
||||
|
||||
rescue StandardError => e
|
||||
puts "ERROR: #{e.class}: #{e.message}"
|
||||
puts 'BACKTRACE:'
|
||||
e.backtrace.each { |line| puts " #{line}" }
|
||||
end
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env ruby
|
||||
|
||||
# Test script for running the Sidekiq job directly
|
||||
require_relative 'config/environment'
|
||||
|
||||
begin
|
||||
puts 'Starting Sidekiq job test...'
|
||||
|
||||
# Find a copilot thread
|
||||
copilot_thread = CopilotThread.last
|
||||
if copilot_thread.nil?
|
||||
puts 'ERROR: No CopilotThread found'
|
||||
exit 1
|
||||
end
|
||||
puts "Found CopilotThread: #{copilot_thread.id}"
|
||||
|
||||
# Run the job directly (not through Sidekiq)
|
||||
puts 'Running AgentsResponseJob directly...'
|
||||
job = Captain::Copilot::AgentsResponseJob.new
|
||||
job.perform(
|
||||
copilot_thread_id: copilot_thread.id,
|
||||
message_content: 'Test message',
|
||||
conversation_id: 14_532,
|
||||
user_id: 1
|
||||
)
|
||||
puts 'Job completed successfully'
|
||||
|
||||
rescue StandardError => e
|
||||
puts "ERROR: #{e.class}: #{e.message}"
|
||||
puts 'BACKTRACE:'
|
||||
e.backtrace.each { |line| puts " #{line}" }
|
||||
end
|
||||
Reference in New Issue
Block a user