Compare commits

...
13 Commits
Author SHA1 Message Date
Shivam Mishra a44eb2c8a5 feat: use feature flag 2025-07-08 20:26:53 +05:30
Shivam Mishra 2b2e4bd8e4 chore: add todo 2025-07-08 20:23:07 +05:30
Shivam Mishra da701b584d chore: remove debug 2025-07-08 20:21:05 +05:30
Shivam Mishra dd46437aa0 chore: update 2025-07-08 20:20:20 +05:30
Shivam Mishra 61ab06a6b1 fix: persist state in redis 2025-07-08 20:18:44 +05:30
Shivam Mishra c349f5c6f9 feat: register callbacks 2025-07-08 18:59:27 +05:30
Shivam Mishra 36786aa314 chore: update agents 2025-07-08 18:59:27 +05:30
PranavandGitHub 1f6f61f018 Merge branch 'develop' into feat/agents 2025-07-06 19:29:21 -07:00
Shivam Mishra 848926e05c style: format 2025-07-03 11:44:43 +05:30
Shivam Mishra 5206aef949 chore: disable debug 2025-07-03 11:06:11 +05:30
Shivam Mishra d10aa6f121 chore: cleanup 2025-07-03 11:01:43 +05:30
Shivam Mishra 165cb850ee feat: setup agents 2025-07-02 20:34:53 +05:30
Shivam Mishra 802465f6d6 feat: setup ai agents 2025-07-02 20:33:23 +05:30
23 changed files with 980 additions and 7 deletions
+2
View File
@@ -180,6 +180,8 @@ gem 'ruby-openai'
gem 'shopify_api'
gem 'ai-agents', '>= 0.2.1'
### Gems required only in specific deployment environments ###
##############################################################
+12
View File
@@ -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)
+3
View File
@@ -176,3 +176,6 @@
- name: notion_integration
display_name: Notion Integration
enabled: false
- name: agents_next
display_name: Use AIAgents SDK
enabled: false
+26
View File
@@ -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
+18 -7
View File
@@ -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
+36
View File
@@ -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
+32
View File
@@ -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