mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	feat: scenario agents & runner (#11944)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
		
							
								
								
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -94,3 +94,4 @@ yarn-debug.log* | |||||||
| .vscode | .vscode | ||||||
| .claude/settings.local.json | .claude/settings.local.json | ||||||
| .cursor | .cursor | ||||||
|  | CLAUDE.local.md | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								Gemfile
									
									
									
									
									
								
							| @@ -179,7 +179,10 @@ gem 'reverse_markdown' | |||||||
|  |  | ||||||
| gem 'iso-639' | gem 'iso-639' | ||||||
| gem 'ruby-openai' | gem 'ruby-openai' | ||||||
| gem 'ai-agents', '>= 0.2.1' | gem 'ai-agents', '>= 0.4.3' | ||||||
|  |  | ||||||
|  | # TODO: Move this gem as a dependency of ai-agents | ||||||
|  | gem 'ruby_llm-schema' | ||||||
|  |  | ||||||
| gem 'shopify_api' | gem 'shopify_api' | ||||||
|  |  | ||||||
|   | |||||||
| @@ -126,7 +126,7 @@ GEM | |||||||
|       jbuilder (~> 2) |       jbuilder (~> 2) | ||||||
|       rails (>= 4.2, < 7.2) |       rails (>= 4.2, < 7.2) | ||||||
|       selectize-rails (~> 0.6) |       selectize-rails (~> 0.6) | ||||||
|     ai-agents (0.2.1) |     ai-agents (0.4.3) | ||||||
|       ruby_llm (~> 1.3) |       ruby_llm (~> 1.3) | ||||||
|     annotate (3.2.0) |     annotate (3.2.0) | ||||||
|       activerecord (>= 3.2, < 8.0) |       activerecord (>= 3.2, < 8.0) | ||||||
| @@ -720,7 +720,7 @@ GEM | |||||||
|     ruby2ruby (2.5.0) |     ruby2ruby (2.5.0) | ||||||
|       ruby_parser (~> 3.1) |       ruby_parser (~> 3.1) | ||||||
|       sexp_processor (~> 4.6) |       sexp_processor (~> 4.6) | ||||||
|     ruby_llm (1.3.1) |     ruby_llm (1.5.1) | ||||||
|       base64 |       base64 | ||||||
|       event_stream_parser (~> 1) |       event_stream_parser (~> 1) | ||||||
|       faraday (>= 1.10.0) |       faraday (>= 1.10.0) | ||||||
| @@ -729,6 +729,7 @@ GEM | |||||||
|       faraday-retry (>= 1) |       faraday-retry (>= 1) | ||||||
|       marcel (~> 1.0) |       marcel (~> 1.0) | ||||||
|       zeitwerk (~> 2) |       zeitwerk (~> 2) | ||||||
|  |     ruby_llm-schema (0.1.0) | ||||||
|     ruby_parser (3.20.0) |     ruby_parser (3.20.0) | ||||||
|       sexp_processor (~> 4.16) |       sexp_processor (~> 4.16) | ||||||
|     sass (3.7.4) |     sass (3.7.4) | ||||||
| @@ -910,7 +911,7 @@ DEPENDENCIES | |||||||
|   administrate (>= 0.20.1) |   administrate (>= 0.20.1) | ||||||
|   administrate-field-active_storage (>= 1.0.3) |   administrate-field-active_storage (>= 1.0.3) | ||||||
|   administrate-field-belongs_to_search (>= 0.9.0) |   administrate-field-belongs_to_search (>= 0.9.0) | ||||||
|   ai-agents (>= 0.2.1) |   ai-agents (>= 0.4.3) | ||||||
|   annotate |   annotate | ||||||
|   attr_extras |   attr_extras | ||||||
|   audited (~> 5.4, >= 5.4.1) |   audited (~> 5.4, >= 5.4.1) | ||||||
| @@ -1004,6 +1005,7 @@ DEPENDENCIES | |||||||
|   rubocop-rails |   rubocop-rails | ||||||
|   rubocop-rspec |   rubocop-rspec | ||||||
|   ruby-openai |   ruby-openai | ||||||
|  |   ruby_llm-schema | ||||||
|   scout_apm |   scout_apm | ||||||
|   scss_lint |   scss_lint | ||||||
|   seed_dump |   seed_dump | ||||||
|   | |||||||
							
								
								
									
										23
									
								
								config/initializers/ai_agents.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								config/initializers/ai_agents.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require '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.presence || OpenAiConstants::DEFAULT_MODEL | ||||||
|  |   api_endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value || OpenAiConstants::DEFAULT_ENDPOINT | ||||||
|  |  | ||||||
|  |   if api_key.present? | ||||||
|  |     Agents.configure do |config| | ||||||
|  |       config.openai_api_key = api_key | ||||||
|  |       if api_endpoint.present? | ||||||
|  |         api_base = "#{api_endpoint.chomp('/')}/v1" | ||||||
|  |         config.openai_api_base = api_base | ||||||
|  |       end | ||||||
|  |       config.default_model = model | ||||||
|  |       config.debug = false | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | rescue StandardError => e | ||||||
|  |   Rails.logger.error "Failed to configure AI Agents SDK: #{e.message}" | ||||||
|  | end | ||||||
| @@ -26,9 +26,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob | |||||||
|   delegate :account, :inbox, to: :@conversation |   delegate :account, :inbox, to: :@conversation | ||||||
|  |  | ||||||
|   def generate_and_process_response |   def generate_and_process_response | ||||||
|     @response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( |     @response = if captain_v2_enabled? | ||||||
|  |                   Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response( | ||||||
|                     message_history: collect_previous_messages |                     message_history: collect_previous_messages | ||||||
|                   ) |                   ) | ||||||
|  |                 else | ||||||
|  |                   Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( | ||||||
|  |                     message_history: collect_previous_messages | ||||||
|  |                   ) | ||||||
|  |                 end | ||||||
|  |  | ||||||
|     return process_action('handoff') if handoff_requested? |     return process_action('handoff') if handoff_requested? | ||||||
|  |  | ||||||
| @@ -104,4 +110,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob | |||||||
|   def log_error(error) |   def log_error(error) | ||||||
|     ChatwootExceptionTracker.new(error, account: account).capture_exception |     ChatwootExceptionTracker.new(error, account: account).capture_exception | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  |   def captain_v2_enabled? | ||||||
|  |     return account.feature_enabled?('captain_integration_v2') | ||||||
|  |   end | ||||||
| end | end | ||||||
|   | |||||||
| @@ -19,6 +19,7 @@ | |||||||
| class Captain::Assistant < ApplicationRecord | class Captain::Assistant < ApplicationRecord | ||||||
|   include Avatarable |   include Avatarable | ||||||
|   include Concerns::CaptainToolsHelpers |   include Concerns::CaptainToolsHelpers | ||||||
|  |   include Concerns::Agentable | ||||||
|  |  | ||||||
|   self.table_name = 'captain_assistants' |   self.table_name = 'captain_assistants' | ||||||
|  |  | ||||||
| @@ -35,6 +36,8 @@ class Captain::Assistant < ApplicationRecord | |||||||
|   has_many :copilot_threads, dependent: :destroy_async |   has_many :copilot_threads, dependent: :destroy_async | ||||||
|   has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async |   has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async | ||||||
|  |  | ||||||
|  |   store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name | ||||||
|  |  | ||||||
|   validates :name, presence: true |   validates :name, presence: true | ||||||
|   validates :description, presence: true |   validates :description, presence: true | ||||||
|   validates :account_id, presence: true |   validates :account_id, presence: true | ||||||
| @@ -71,6 +74,33 @@ class Captain::Assistant < ApplicationRecord | |||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def agent_name | ||||||
|  |     name | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_tools | ||||||
|  |     [ | ||||||
|  |       self.class.resolve_tool_class('faq_lookup').new(self), | ||||||
|  |       self.class.resolve_tool_class('handoff').new(self) | ||||||
|  |     ] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def prompt_context | ||||||
|  |     { | ||||||
|  |       name: name, | ||||||
|  |       description: description, | ||||||
|  |       product_name: config['product_name'] || 'this product', | ||||||
|  |       scenarios: scenarios.enabled.map do |scenario| | ||||||
|  |         { | ||||||
|  |           key: scenario.title.parameterize.underscore, | ||||||
|  |           description: scenario.description | ||||||
|  |         } | ||||||
|  |       end, | ||||||
|  |       response_guidelines: response_guidelines || [], | ||||||
|  |       guardrails: guardrails || [] | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|   def default_avatar_url |   def default_avatar_url | ||||||
|     "#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg" |     "#{ENV.fetch('FRONTEND_URL', nil)}/assets/images/dashboard/captain/logo.svg" | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -22,6 +22,7 @@ | |||||||
| # | # | ||||||
| class Captain::Scenario < ApplicationRecord | class Captain::Scenario < ApplicationRecord | ||||||
|   include Concerns::CaptainToolsHelpers |   include Concerns::CaptainToolsHelpers | ||||||
|  |   include Concerns::Agentable | ||||||
|  |  | ||||||
|   self.table_name = 'captain_scenarios' |   self.table_name = 'captain_scenarios' | ||||||
|  |  | ||||||
| @@ -37,10 +38,43 @@ class Captain::Scenario < ApplicationRecord | |||||||
|  |  | ||||||
|   scope :enabled, -> { where(enabled: true) } |   scope :enabled, -> { where(enabled: true) } | ||||||
|  |  | ||||||
|  |   delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant | ||||||
|  |  | ||||||
|   before_save :resolve_tool_references |   before_save :resolve_tool_references | ||||||
|  |  | ||||||
|  |   def prompt_context | ||||||
|  |     { | ||||||
|  |       title: title, | ||||||
|  |       instructions: resolved_instructions, | ||||||
|  |       tools: resolved_tools | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|  |  | ||||||
|  |   def agent_name | ||||||
|  |     "#{title} Agent".titleize | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_tools | ||||||
|  |     resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def resolved_instructions | ||||||
|  |     instruction.gsub(TOOL_REFERENCE_REGEX) do |match| | ||||||
|  |       "#{match} tool " | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def resolved_tools | ||||||
|  |     return [] if tools.blank? | ||||||
|  |  | ||||||
|  |     available_tools = self.class.available_agent_tools | ||||||
|  |     tools.filter_map do |tool_id| | ||||||
|  |       available_tools.find { |tool| tool[:id] == tool_id } | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|   # Validates that all tool references in the instruction are valid. |   # Validates that all tool references in the instruction are valid. | ||||||
|   # Parses the instruction for tool references and checks if they exist |   # Parses the instruction for tool references and checks if they exist | ||||||
|   # in the available tools configuration. |   # in the available tools configuration. | ||||||
|   | |||||||
							
								
								
									
										56
									
								
								enterprise/app/models/concerns/agentable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								enterprise/app/models/concerns/agentable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | module Concerns::Agentable | ||||||
|  |   extend ActiveSupport::Concern | ||||||
|  |  | ||||||
|  |   def agent | ||||||
|  |     Agents::Agent.new( | ||||||
|  |       name: agent_name, | ||||||
|  |       instructions: ->(context) { agent_instructions(context) }, | ||||||
|  |       tools: agent_tools, | ||||||
|  |       model: agent_model, | ||||||
|  |       temperature: temperature.to_f || 0.7, | ||||||
|  |       response_schema: agent_response_schema | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_instructions(context = nil) | ||||||
|  |     enhanced_context = prompt_context | ||||||
|  |  | ||||||
|  |     if context | ||||||
|  |       state = context.context[:state] || {} | ||||||
|  |       conversation_data = state[:conversation] || {} | ||||||
|  |       contact_data = state[:contact] || {} | ||||||
|  |       enhanced_context = enhanced_context.merge( | ||||||
|  |         conversation: conversation_data, | ||||||
|  |         contact: contact_data | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def agent_name | ||||||
|  |     raise NotImplementedError, "#{self.class} must implement agent_name" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def template_name | ||||||
|  |     self.class.name.demodulize.underscore | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_tools | ||||||
|  |     []  # Default implementation, override if needed | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_model | ||||||
|  |     InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || OpenAiConstants::DEFAULT_MODEL | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def agent_response_schema | ||||||
|  |     Captain::ResponseSchema | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def prompt_context | ||||||
|  |     raise NotImplementedError, "#{self.class} must implement prompt_context" | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -0,0 +1,161 @@ | |||||||
|  | require 'agents' | ||||||
|  |  | ||||||
|  | class Captain::Assistant::AgentRunnerService | ||||||
|  |   CONVERSATION_STATE_ATTRIBUTES = %i[ | ||||||
|  |     id display_id inbox_id contact_id status priority | ||||||
|  |     label_list custom_attributes additional_attributes | ||||||
|  |   ].freeze | ||||||
|  |  | ||||||
|  |   CONTACT_STATE_ATTRIBUTES = %i[ | ||||||
|  |     id name email phone_number identifier contact_type | ||||||
|  |     custom_attributes additional_attributes | ||||||
|  |   ].freeze | ||||||
|  |  | ||||||
|  |   def initialize(assistant:, conversation: nil, callbacks: {}) | ||||||
|  |     @assistant = assistant | ||||||
|  |     @conversation = conversation | ||||||
|  |     @callbacks = callbacks | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def generate_response(message_history: []) | ||||||
|  |     agents = build_and_wire_agents | ||||||
|  |     context = build_context(message_history) | ||||||
|  |     message_to_process = extract_last_user_message(message_history) | ||||||
|  |     runner = Agents::Runner.with_agents(*agents) | ||||||
|  |     runner = add_callbacks_to_runner(runner) if @callbacks.any? | ||||||
|  |     result = runner.run(message_to_process, context: context) | ||||||
|  |  | ||||||
|  |     process_agent_result(result) | ||||||
|  |   rescue StandardError => e | ||||||
|  |     # when running the agent runner service in a rake task, the conversation might not have an account associated | ||||||
|  |     # for regular production usage, it will run just fine | ||||||
|  |     ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception | ||||||
|  |     Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}" | ||||||
|  |     Rails.logger.error e.backtrace.join("\n") | ||||||
|  |  | ||||||
|  |     error_response(e.message) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def build_context(message_history) | ||||||
|  |     conversation_history = message_history.map do |msg| | ||||||
|  |       content = extract_text_from_content(msg[:content]) | ||||||
|  |  | ||||||
|  |       { | ||||||
|  |         role: msg[:role].to_sym, | ||||||
|  |         content: content, | ||||||
|  |         agent_name: msg[:agent_name] | ||||||
|  |       } | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     { | ||||||
|  |       conversation_history: conversation_history, | ||||||
|  |       state: build_state | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_last_user_message(message_history) | ||||||
|  |     last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' } | ||||||
|  |  | ||||||
|  |     extract_text_from_content(last_user_msg[:content]) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_text_from_content(content) | ||||||
|  |     # Handle structured output from agents | ||||||
|  |     return content[:response] || content['response'] || content.to_s if content.is_a?(Hash) | ||||||
|  |  | ||||||
|  |     return content unless content.is_a?(Array) | ||||||
|  |  | ||||||
|  |     text_parts = content.select { |part| part[:type] == 'text' }.pluck(:text) | ||||||
|  |     text_parts.join(' ') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   # Response formatting methods | ||||||
|  |   def process_agent_result(result) | ||||||
|  |     Rails.logger.info "[Captain V2] Agent result: #{result.inspect}" | ||||||
|  |     format_response(result.output) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def format_response(output) | ||||||
|  |     return output.with_indifferent_access if output.is_a?(Hash) | ||||||
|  |  | ||||||
|  |     # Fallback for backwards compatibility | ||||||
|  |     { | ||||||
|  |       'response' => output.to_s, | ||||||
|  |       'reasoning' => 'Processed by agent' | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def error_response(error_message) | ||||||
|  |     { | ||||||
|  |       'response' => 'conversation_handoff', | ||||||
|  |       'reasoning' => "Error occurred: #{error_message}" | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def build_state | ||||||
|  |     state = { | ||||||
|  |       account_id: @assistant.account_id, | ||||||
|  |       assistant_id: @assistant.id, | ||||||
|  |       assistant_config: @assistant.config | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if @conversation | ||||||
|  |       state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) | ||||||
|  |       state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     state | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def build_and_wire_agents | ||||||
|  |     assistant_agent = @assistant.agent | ||||||
|  |     scenario_agents = @assistant.scenarios.enabled.map(&:agent) | ||||||
|  |  | ||||||
|  |     assistant_agent.register_handoffs(*scenario_agents) if scenario_agents.any? | ||||||
|  |     scenario_agents.each { |scenario_agent| scenario_agent.register_handoffs(assistant_agent) } | ||||||
|  |  | ||||||
|  |     [assistant_agent] + scenario_agents | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_callbacks_to_runner(runner) | ||||||
|  |     runner = add_agent_thinking_callback(runner) if @callbacks[:on_agent_thinking] | ||||||
|  |     runner = add_tool_start_callback(runner) if @callbacks[:on_tool_start] | ||||||
|  |     runner = add_tool_complete_callback(runner) if @callbacks[:on_tool_complete] | ||||||
|  |     runner = add_agent_handoff_callback(runner) if @callbacks[:on_agent_handoff] | ||||||
|  |     runner | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_agent_thinking_callback(runner) | ||||||
|  |     runner.on_agent_thinking do |*args| | ||||||
|  |       @callbacks[:on_agent_thinking].call(*args) | ||||||
|  |     rescue StandardError => e | ||||||
|  |       Rails.logger.warn "[Captain] Callback error for agent_thinking: #{e.message}" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_tool_start_callback(runner) | ||||||
|  |     runner.on_tool_start do |*args| | ||||||
|  |       @callbacks[:on_tool_start].call(*args) | ||||||
|  |     rescue StandardError => e | ||||||
|  |       Rails.logger.warn "[Captain] Callback error for tool_start: #{e.message}" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_tool_complete_callback(runner) | ||||||
|  |     runner.on_tool_complete do |*args| | ||||||
|  |       @callbacks[:on_tool_complete].call(*args) | ||||||
|  |     rescue StandardError => e | ||||||
|  |       Rails.logger.warn "[Captain] Callback error for tool_complete: #{e.message}" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_agent_handoff_callback(runner) | ||||||
|  |     runner.on_agent_handoff do |*args| | ||||||
|  |       @callbacks[:on_agent_handoff].call(*args) | ||||||
|  |     rescue StandardError => e | ||||||
|  |       Rails.logger.warn "[Captain] Callback error for agent_handoff: #{e.message}" | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										25
									
								
								enterprise/lib/captain/prompt_renderer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								enterprise/lib/captain/prompt_renderer.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | |||||||
|  | require 'liquid' | ||||||
|  |  | ||||||
|  | class Captain::PromptRenderer | ||||||
|  |   class << self | ||||||
|  |     def render(template_name, context = {}) | ||||||
|  |       template = load_template(template_name) | ||||||
|  |       liquid_template = Liquid::Template.parse(template) | ||||||
|  |       liquid_template.render(stringify_keys(context)) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     private | ||||||
|  |  | ||||||
|  |     def load_template(template_name) | ||||||
|  |       template_path = Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") | ||||||
|  |  | ||||||
|  |       raise "Template not found: #{template_name}" unless File.exist?(template_path) | ||||||
|  |  | ||||||
|  |       File.read(template_path) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     def stringify_keys(hash) | ||||||
|  |       hash.deep_stringify_keys | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										80
									
								
								enterprise/lib/captain/prompts/assistant.liquid
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								enterprise/lib/captain/prompts/assistant.liquid
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,80 @@ | |||||||
|  | # System Context | ||||||
|  | You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses. | ||||||
|  |  | ||||||
|  | # Your Identity | ||||||
|  | You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need. | ||||||
|  |  | ||||||
|  | {{ description }} | ||||||
|  |  | ||||||
|  | Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this. | ||||||
|  |  | ||||||
|  | # Current Context | ||||||
|  |  | ||||||
|  | Here's the metadata we have about the current conversation and the contact associated with it: | ||||||
|  |  | ||||||
|  | {% if conversation -%} | ||||||
|  | {% render 'conversation' %} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
|  | {% if contact -%} | ||||||
|  | {% render 'contact' %} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
|  | {% if response_guidelines.size > 0 -%} | ||||||
|  | # Response Guidelines | ||||||
|  | Your responses should follow these guidelines: | ||||||
|  | {% for guideline in response_guidelines -%} | ||||||
|  | - {{ guideline }} | ||||||
|  | {% endfor %} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
|  | {% if guardrails.size > 0 -%} | ||||||
|  | # Guardrails | ||||||
|  | Always respect these boundaries: | ||||||
|  | {% for guardrail in guardrails -%} | ||||||
|  | - {{ guardrail }} | ||||||
|  | {% endfor %} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
|  | # Decision Framework | ||||||
|  |  | ||||||
|  | ## 1. Analyze the Request | ||||||
|  | First, understand what the user is asking: | ||||||
|  | - **Intent**: What are they trying to achieve? | ||||||
|  | - **Type**: Is it a question, task, complaint, or request? | ||||||
|  | - **Complexity**: Can you handle it or does it need specialized expertise? | ||||||
|  |  | ||||||
|  | ## 2. Check for Specialized Scenarios First | ||||||
|  | Before using any tools, check if the request matches any of these scenarios. If unclear, ask clarifying questions to determine if a scenario applies: | ||||||
|  |  | ||||||
|  | {% for scenario in scenarios -%} | ||||||
|  | ### handoff_to_{{ scenario.key }} | ||||||
|  | {{ scenario.description }} | ||||||
|  | {% endfor -%} | ||||||
|  |  | ||||||
|  | ## 3. Handle the Request | ||||||
|  | If no specialized scenario clearly matches, handle it yourself: | ||||||
|  |  | ||||||
|  | ### For Questions and Information Requests | ||||||
|  | 1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information | ||||||
|  | 2. **If not found in FAQs**: Provide your best answer based on available context | ||||||
|  | 3. **If unable to answer**: Use `handoff` tool to transfer to a human expert | ||||||
|  |  | ||||||
|  | ### For Complex or Unclear Requests | ||||||
|  | 1. **Ask clarifying questions**: Gather more information if needed | ||||||
|  | 2. **Break down complex tasks**: Handle step by step or hand off if too complex | ||||||
|  | 3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities | ||||||
|  |  | ||||||
|  | ## Response Best Practices | ||||||
|  | - Be conversational but professional | ||||||
|  | - Provide actionable information | ||||||
|  | - Include relevant details from tool responses | ||||||
|  |  | ||||||
|  | # Human Handoff Protocol | ||||||
|  | Transfer to a human agent when: | ||||||
|  | - User explicitly requests human assistance | ||||||
|  | - You cannot find needed information after checking FAQs | ||||||
|  | - The issue requires specialized knowledge or permissions you don't have | ||||||
|  | - Multiple attempts to help have been unsuccessful | ||||||
|  |  | ||||||
|  | When using the `handoff` tool, provide a clear reason that helps the human agent understand the context. | ||||||
							
								
								
									
										24
									
								
								enterprise/lib/captain/prompts/scenario.liquid
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								enterprise/lib/captain/prompts/scenario.liquid
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | # System context | ||||||
|  | You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. | ||||||
|  | The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally. | ||||||
|  |  | ||||||
|  | # Your Role | ||||||
|  | You are a specialized agent called {{ title }}, your task is to handle the following scenario: | ||||||
|  |  | ||||||
|  | {{ instructions }} | ||||||
|  |  | ||||||
|  | {% if conversation -%} | ||||||
|  | {% render 'conversation' %} | ||||||
|  |  | ||||||
|  | {% if contact -%} | ||||||
|  | {% render 'contact' %} | ||||||
|  | {% endif -%} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
|  | {% if tools.size > 0 -%} | ||||||
|  | # Available Tools | ||||||
|  | You have access to these tools: | ||||||
|  | {% for tool in tools -%} | ||||||
|  | - {{ tool.id }}: {{ tool.description }} | ||||||
|  | {% endfor %} | ||||||
|  | {%- endif %} | ||||||
							
								
								
									
										17
									
								
								enterprise/lib/captain/prompts/snippets/contact.liquid
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								enterprise/lib/captain/prompts/snippets/contact.liquid
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | |||||||
|  | # Contact Information | ||||||
|  | - Contact ID: {{ contact.id }} | ||||||
|  | - Name: {{ contact.name || "Unknown" }} | ||||||
|  | - Email: {{ contact.email || "None" }} | ||||||
|  | - Phone: {{ contact.phone_number || "None" }} | ||||||
|  | - Identifier: {{ contact.identifier || "None" }} | ||||||
|  | - Type: {{ contact.contact_type || "visitor" }} | ||||||
|  | {% if contact.custom_attributes -%} | ||||||
|  |   {% for attribute in contact.custom_attributes -%} | ||||||
|  | - {{ attribute[0] }}: {{ attribute[1] }} | ||||||
|  |   {% endfor -%} | ||||||
|  | {% endif -%} | ||||||
|  | {% if contact.additional_attributes -%} | ||||||
|  |   {% for attribute in contact.additional_attributes -%} | ||||||
|  | - {{ attribute[0] }}: {{ attribute[1] }} | ||||||
|  |   {% endfor -%} | ||||||
|  | {% endif -%} | ||||||
							
								
								
									
										18
									
								
								enterprise/lib/captain/prompts/snippets/conversation.liquid
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								enterprise/lib/captain/prompts/snippets/conversation.liquid
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | |||||||
|  | # Current Conversation Context | ||||||
|  | - Conversation ID: {{ conversation.display_id }} | ||||||
|  | - Contact ID: {{ conversation.contact_id }} | ||||||
|  | - Status: {{ conversation.status }} | ||||||
|  | - Priority: {{ conversation.priority || "None" }} | ||||||
|  | {% if conversation.label_list.size > 0 -%} | ||||||
|  | - Labels: {{ conversation.label_list | join: ", " }} | ||||||
|  | {% endif -%} | ||||||
|  | {% if conversation.custom_attributes -%} | ||||||
|  |   {% for attribute in conversation.custom_attributes -%} | ||||||
|  | - {{ attribute[0] }}: {{ attribute[1] }} | ||||||
|  |   {% endfor -%} | ||||||
|  | {% endif -%} | ||||||
|  | {% if conversation.additional_attributes -%} | ||||||
|  |   {% for attribute in conversation.additional_attributes -%} | ||||||
|  | - {{ attribute[0] }}: {{ attribute[1] }} | ||||||
|  |   {% endfor -%} | ||||||
|  | {% endif -%} | ||||||
							
								
								
									
										6
									
								
								enterprise/lib/captain/response_schema.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								enterprise/lib/captain/response_schema.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | # TODO: Wrap the schema lib under ai-agents | ||||||
|  | # So we can extend it as Agents::Schema | ||||||
|  | class Captain::ResponseSchema < RubyLLM::Schema | ||||||
|  |   string :response, description: 'The message to send to the user' | ||||||
|  |   string :reasoning, description: "Agent's thought process" | ||||||
|  | end | ||||||
							
								
								
									
										6
									
								
								lib/open_ai_constants.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/open_ai_constants.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | module OpenAiConstants | ||||||
|  |   DEFAULT_MODEL = 'gpt-4.1-mini' | ||||||
|  |   DEFAULT_ENDPOINT = 'https://api.openai.com' | ||||||
|  | end | ||||||
							
								
								
									
										235
									
								
								lib/tasks/captain_chat.rake
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										235
									
								
								lib/tasks/captain_chat.rake
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,235 @@ | |||||||
|  | require 'io/console' | ||||||
|  | require 'readline' | ||||||
|  |  | ||||||
|  | namespace :captain do | ||||||
|  |   desc 'Start interactive chat with Captain assistant - Usage: rake captain:chat[assistant_id] or rake captain:chat -- assistant_id' | ||||||
|  |   task :chat, [:assistant_id] => :environment do |_, args| | ||||||
|  |     assistant_id = args[:assistant_id] || ARGV[1] | ||||||
|  |  | ||||||
|  |     unless assistant_id | ||||||
|  |       puts '❌ Please provide an assistant ID' | ||||||
|  |       puts 'Usage: rake captain:chat[assistant_id]' | ||||||
|  |       puts "\nAvailable assistants:" | ||||||
|  |       Captain::Assistant.includes(:account).each do |assistant| | ||||||
|  |         puts "  ID: #{assistant.id} - #{assistant.name} (Account: #{assistant.account.name})" | ||||||
|  |       end | ||||||
|  |       exit 1 | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     assistant = Captain::Assistant.find_by(id: assistant_id) | ||||||
|  |     unless assistant | ||||||
|  |       puts "❌ Assistant with ID #{assistant_id} not found" | ||||||
|  |       exit 1 | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     # Clear ARGV to prevent gets from reading files | ||||||
|  |     ARGV.clear | ||||||
|  |  | ||||||
|  |     chat_session = CaptainChatSession.new(assistant) | ||||||
|  |     chat_session.start | ||||||
|  |   end | ||||||
|  | end | ||||||
|  |  | ||||||
|  | class CaptainChatSession | ||||||
|  |   def initialize(assistant) | ||||||
|  |     @assistant = assistant | ||||||
|  |     @message_history = [] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def start | ||||||
|  |     show_assistant_info | ||||||
|  |     show_instructions | ||||||
|  |     chat_loop | ||||||
|  |     show_exit_message | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def show_instructions | ||||||
|  |     puts "💡 Type 'exit', 'quit', or 'bye' to end the session" | ||||||
|  |     puts "💡 Type 'clear' to clear message history" | ||||||
|  |     puts('-' * 50) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def chat_loop | ||||||
|  |     loop do | ||||||
|  |       puts '' # Add spacing before prompt | ||||||
|  |       user_input = Readline.readline('👤 You: ', true) | ||||||
|  |       next unless user_input # Handle Ctrl+D | ||||||
|  |  | ||||||
|  |       break unless handle_user_input(user_input.strip) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_user_input(user_input) | ||||||
|  |     case user_input.downcase | ||||||
|  |     when 'exit', 'quit', 'bye' | ||||||
|  |       false | ||||||
|  |     when 'clear' | ||||||
|  |       clear_history | ||||||
|  |       true | ||||||
|  |     when '' | ||||||
|  |       true | ||||||
|  |     else | ||||||
|  |       process_user_message(user_input) | ||||||
|  |       true | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show_exit_message | ||||||
|  |     puts "\nChat session ended" | ||||||
|  |     puts "Final conversation log has #{@message_history.length} messages" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show_assistant_info | ||||||
|  |     show_basic_info | ||||||
|  |     show_scenarios | ||||||
|  |     show_available_tools | ||||||
|  |     puts '' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show_basic_info | ||||||
|  |     puts "🤖 Starting chat with #{@assistant.name}" | ||||||
|  |     puts "🏢 Account: #{@assistant.account.name}" | ||||||
|  |     puts "🆔 Assistant ID: #{@assistant.id}" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show_scenarios | ||||||
|  |     scenarios = @assistant.scenarios.enabled | ||||||
|  |     if scenarios.any? | ||||||
|  |       puts "⚡ Enabled Scenarios (#{scenarios.count}):" | ||||||
|  |       scenarios.each { |scenario| display_scenario(scenario) } | ||||||
|  |     else | ||||||
|  |       puts '⚡ No scenarios enabled' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def display_scenario(scenario) | ||||||
|  |     tools_count = scenario.tools&.length || 0 | ||||||
|  |     puts "   • #{scenario.title} (#{tools_count} tools)" | ||||||
|  |     return if scenario.description.blank? | ||||||
|  |  | ||||||
|  |     description = truncate_description(scenario.description) | ||||||
|  |     puts "     #{description}" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def truncate_description(description) | ||||||
|  |     description.length > 60 ? "#{description[0..60]}..." : description | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def show_available_tools | ||||||
|  |     available_tools = Captain::Assistant.available_tool_ids | ||||||
|  |     if available_tools.any? | ||||||
|  |       puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}" | ||||||
|  |     else | ||||||
|  |       puts '🔧 No tools available' | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_user_message(user_input) | ||||||
|  |     add_to_history('user', user_input) | ||||||
|  |  | ||||||
|  |     begin | ||||||
|  |       print "🤖 #{@assistant.name}: " | ||||||
|  |       @current_system_messages = [] | ||||||
|  |  | ||||||
|  |       result = generate_assistant_response | ||||||
|  |       display_response(result) | ||||||
|  |     rescue StandardError => e | ||||||
|  |       handle_error(e) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def generate_assistant_response | ||||||
|  |     runner = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, callbacks: build_callbacks) | ||||||
|  |     runner.generate_response(message_history: @message_history) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def build_callbacks | ||||||
|  |     { | ||||||
|  |       on_agent_thinking: method(:handle_agent_thinking), | ||||||
|  |       on_tool_start: method(:handle_tool_start), | ||||||
|  |       on_tool_complete: method(:handle_tool_complete), | ||||||
|  |       on_agent_handoff: method(:handle_agent_handoff) | ||||||
|  |     } | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_agent_thinking(agent, _input) | ||||||
|  |     agent_name = extract_name(agent) | ||||||
|  |     @current_system_messages << "#{agent_name} is thinking..." | ||||||
|  |     add_to_history('system', "#{agent_name} is thinking...") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_tool_start(tool, _args) | ||||||
|  |     tool_name = extract_tool_name(tool) | ||||||
|  |     @current_system_messages << "Using tool: #{tool_name}" | ||||||
|  |     add_to_history('system', "Using tool: #{tool_name}") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_tool_complete(tool, _result) | ||||||
|  |     tool_name = extract_tool_name(tool) | ||||||
|  |     @current_system_messages << "Tool #{tool_name} completed" | ||||||
|  |     add_to_history('system', "Tool #{tool_name} completed") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_agent_handoff(from, to, reason) | ||||||
|  |     @current_system_messages << "Handoff: #{extract_name(from)} → #{extract_name(to)} (#{reason})" | ||||||
|  |     add_to_history('system', "Agent handoff: #{extract_name(from)} → #{extract_name(to)} (#{reason})") | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def display_response(result) | ||||||
|  |     response_text = result['response'] || 'No response generated' | ||||||
|  |     reasoning = result['reasoning'] | ||||||
|  |  | ||||||
|  |     puts dim_text("\n#{@current_system_messages.join("\n")}") if @current_system_messages.any? | ||||||
|  |     puts response_text | ||||||
|  |     puts dim_italic_text("(Reasoning: #{reasoning})") if reasoning && reasoning != 'Processed by agent' | ||||||
|  |  | ||||||
|  |     add_to_history('assistant', response_text, reasoning: reasoning) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_error(error) | ||||||
|  |     error_msg = "Error: #{error.message}" | ||||||
|  |     puts "❌ #{error_msg}" | ||||||
|  |     add_to_history('system', error_msg) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def add_to_history(role, content, agent_name: nil, reasoning: nil) | ||||||
|  |     message = { | ||||||
|  |       role: role, | ||||||
|  |       content: content, | ||||||
|  |       timestamp: Time.current, | ||||||
|  |       agent_name: agent_name || (role == 'assistant' ? @assistant.name : nil) | ||||||
|  |     } | ||||||
|  |     message[:reasoning] = reasoning if reasoning | ||||||
|  |  | ||||||
|  |     @message_history << message | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def clear_history | ||||||
|  |     @message_history.clear | ||||||
|  |     puts 'Message history cleared' | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def dim_text(text) | ||||||
|  |     # ANSI escape code for very dim gray text (bright black/dark gray) | ||||||
|  |     "\e[90m#{text}\e[0m" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def dim_italic_text(text) | ||||||
|  |     # ANSI escape codes for dim gray + italic text | ||||||
|  |     "\e[90m\e[3m#{text}\e[0m" | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_tool_name(tool) | ||||||
|  |     return tool if tool.is_a?(String) | ||||||
|  |  | ||||||
|  |     tool.class.name.split('::').last.gsub('Tool', '') | ||||||
|  |   rescue StandardError | ||||||
|  |     tool.to_s | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def extract_name(obj) | ||||||
|  |     obj.respond_to?(:name) ? obj.name : obj.to_s | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -9,6 +9,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do | |||||||
|   describe '#perform' do |   describe '#perform' do | ||||||
|     let(:conversation) { create(:conversation, inbox: inbox, account: account) } |     let(:conversation) { create(:conversation, inbox: inbox, account: account) } | ||||||
|     let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } |     let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } | ||||||
|  |     let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) } | ||||||
|  |  | ||||||
|     before do |     before do | ||||||
|       create(:message, conversation: conversation, content: 'Hello', message_type: :incoming) |       create(:message, conversation: conversation, content: 'Hello', message_type: :incoming) | ||||||
| @@ -16,6 +17,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do | |||||||
|       allow(inbox).to receive(:captain_active?).and_return(true) |       allow(inbox).to receive(:captain_active?).and_return(true) | ||||||
|       allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service) |       allow(Captain::Llm::AssistantChatService).to receive(:new).and_return(mock_llm_chat_service) | ||||||
|       allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' }) |       allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' }) | ||||||
|  |       allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service) | ||||||
|  |       allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' }) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when captain_v2 is disabled' do | ||||||
|  |       before do | ||||||
|  |         allow(account).to receive(:feature_enabled?).and_return(false) | ||||||
|  |         allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'uses Captain::Llm::AssistantChatService' do | ||||||
|  |         expect(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant) | ||||||
|  |         expect(Captain::Assistant::AgentRunnerService).not_to receive(:new) | ||||||
|  |  | ||||||
|  |         described_class.perform_now(conversation, assistant) | ||||||
|  |         expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs') | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       it 'generates and processes response' do |       it 'generates and processes response' do | ||||||
| @@ -30,6 +47,50 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do | |||||||
|         account.reload |         account.reload | ||||||
|         expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) |         expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) | ||||||
|       end |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when captain_v2 is enabled' do | ||||||
|  |       before do | ||||||
|  |         allow(account).to receive(:feature_enabled?).and_return(false) | ||||||
|  |         allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'uses Captain::Assistant::AgentRunnerService' do | ||||||
|  |         expect(Captain::Assistant::AgentRunnerService).to receive(:new).with( | ||||||
|  |           assistant: assistant, | ||||||
|  |           conversation: conversation | ||||||
|  |         ) | ||||||
|  |         expect(Captain::Llm::AssistantChatService).not_to receive(:new) | ||||||
|  |  | ||||||
|  |         described_class.perform_now(conversation, assistant) | ||||||
|  |         expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2') | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'passes message history to agent runner service' do | ||||||
|  |         expected_messages = [ | ||||||
|  |           { content: 'Hello', role: 'user' } | ||||||
|  |         ] | ||||||
|  |  | ||||||
|  |         expect(mock_agent_runner_service).to receive(:generate_response).with( | ||||||
|  |           message_history: expected_messages | ||||||
|  |         ) | ||||||
|  |  | ||||||
|  |         described_class.perform_now(conversation, assistant) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'generates and processes response' do | ||||||
|  |         described_class.perform_now(conversation, assistant) | ||||||
|  |         expect(conversation.messages.count).to eq(2) | ||||||
|  |         expect(conversation.messages.outgoing.count).to eq(1) | ||||||
|  |         expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2') | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'increments usage response' do | ||||||
|  |         described_class.perform_now(conversation, assistant) | ||||||
|  |         account.reload | ||||||
|  |         expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|     context 'when message contains an image' do |     context 'when message contains an image' do | ||||||
|       let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } |       let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } | ||||||
|   | |||||||
							
								
								
									
										123
									
								
								spec/enterprise/lib/captain/prompt_renderer_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										123
									
								
								spec/enterprise/lib/captain/prompt_renderer_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,123 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Captain::PromptRenderer do | ||||||
|  |   let(:template_name) { 'test_template' } | ||||||
|  |   let(:template_content) { 'Hello {{name}}, your balance is {{balance}}' } | ||||||
|  |   let(:template_path) { Rails.root.join('enterprise', 'lib', 'captain', 'prompts', "#{template_name}.liquid") } | ||||||
|  |   let(:context) { { name: 'John', balance: 100 } } | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     allow(File).to receive(:exist?).and_return(false) | ||||||
|  |     allow(File).to receive(:exist?).with(template_path).and_return(true) | ||||||
|  |     allow(File).to receive(:read).with(template_path).and_return(template_content) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '.render' do | ||||||
|  |     it 'renders template with context' do | ||||||
|  |       result = described_class.render(template_name, context) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Hello John, your balance is 100') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles string keys in context' do | ||||||
|  |       string_context = { 'name' => 'Jane', 'balance' => 200 } | ||||||
|  |       result = described_class.render(template_name, string_context) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Hello Jane, your balance is 200') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles mixed symbol and string keys' do | ||||||
|  |       mixed_context = { :name => 'Bob', 'balance' => 300 } | ||||||
|  |       result = described_class.render(template_name, mixed_context) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Hello Bob, your balance is 300') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles nested hash context' do | ||||||
|  |       nested_template = 'User: {{user.name}}, Account: {{user.account.type}}' | ||||||
|  |       nested_context = { user: { name: 'Alice', account: { type: 'premium' } } } | ||||||
|  |  | ||||||
|  |       allow(File).to receive(:read).with(template_path).and_return(nested_template) | ||||||
|  |  | ||||||
|  |       result = described_class.render(template_name, nested_context) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('User: Alice, Account: premium') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles empty context' do | ||||||
|  |       simple_template = 'Hello World' | ||||||
|  |       allow(File).to receive(:read).with(template_path).and_return(simple_template) | ||||||
|  |  | ||||||
|  |       result = described_class.render(template_name, {}) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Hello World') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'loads and parses liquid template' do | ||||||
|  |       liquid_template_double = instance_double(Liquid::Template) | ||||||
|  |       allow(Liquid::Template).to receive(:parse).with(template_content).and_return(liquid_template_double) | ||||||
|  |       allow(liquid_template_double).to receive(:render).with(hash_including('name', 'balance')).and_return('rendered') | ||||||
|  |  | ||||||
|  |       result = described_class.render(template_name, context) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('rendered') | ||||||
|  |       expect(Liquid::Template).to have_received(:parse).with(template_content) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '.load_template' do | ||||||
|  |     it 'reads template file from correct path' do | ||||||
|  |       described_class.send(:load_template, template_name) | ||||||
|  |  | ||||||
|  |       expect(File).to have_received(:read).with(template_path) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'raises error when template does not exist' do | ||||||
|  |       allow(File).to receive(:exist?).with(template_path).and_return(false) | ||||||
|  |  | ||||||
|  |       expect { described_class.send(:load_template, template_name) } | ||||||
|  |         .to raise_error("Template not found: #{template_name}") | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'constructs correct template path' do | ||||||
|  |       expected_path = Rails.root.join('enterprise/lib/captain/prompts/my_template.liquid') | ||||||
|  |       allow(File).to receive(:exist?).with(expected_path).and_return(true) | ||||||
|  |       allow(File).to receive(:read).with(expected_path).and_return('test content') | ||||||
|  |  | ||||||
|  |       described_class.send(:load_template, 'my_template') | ||||||
|  |  | ||||||
|  |       expect(File).to have_received(:exist?).with(expected_path) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '.stringify_keys' do | ||||||
|  |     it 'converts symbol keys to strings' do | ||||||
|  |       hash = { name: 'John', age: 30 } | ||||||
|  |       result = described_class.send(:stringify_keys, hash) | ||||||
|  |  | ||||||
|  |       expect(result).to eq({ 'name' => 'John', 'age' => 30 }) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles nested hashes' do | ||||||
|  |       hash = { user: { name: 'John', profile: { age: 30 } } } | ||||||
|  |       result = described_class.send(:stringify_keys, hash) | ||||||
|  |  | ||||||
|  |       expect(result).to eq({ 'user' => { 'name' => 'John', 'profile' => { 'age' => 30 } } }) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles arrays with hashes' do | ||||||
|  |       hash = { users: [{ name: 'John' }, { name: 'Jane' }] } | ||||||
|  |       result = described_class.send(:stringify_keys, hash) | ||||||
|  |  | ||||||
|  |       expect(result).to eq({ 'users' => [{ 'name' => 'John' }, { 'name' => 'Jane' }] }) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles empty hash' do | ||||||
|  |       result = described_class.send(:stringify_keys, {}) | ||||||
|  |  | ||||||
|  |       expect(result).to eq({}) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										186
									
								
								spec/enterprise/models/concerns/agentable_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										186
									
								
								spec/enterprise/models/concerns/agentable_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,186 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Concerns::Agentable do | ||||||
|  |   let(:dummy_class) do | ||||||
|  |     Class.new do | ||||||
|  |       include Concerns::Agentable | ||||||
|  |  | ||||||
|  |       attr_accessor :temperature | ||||||
|  |  | ||||||
|  |       def initialize(name: 'Test Agent', temperature: 0.8) | ||||||
|  |         @name = name | ||||||
|  |         @temperature = temperature | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       def self.name | ||||||
|  |         'DummyClass' | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       private | ||||||
|  |  | ||||||
|  |       def agent_name | ||||||
|  |         @name | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       def prompt_context | ||||||
|  |         { base_key: 'base_value' } | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   let(:dummy_instance) { dummy_class.new } | ||||||
|  |   let(:mock_agents_agent) { instance_double(Agents::Agent) } | ||||||
|  |   let(:mock_installation_config) { instance_double(InstallationConfig, value: 'gpt-4-turbo') } | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     allow(Agents::Agent).to receive(:new).and_return(mock_agents_agent) | ||||||
|  |     allow(InstallationConfig).to receive(:find_by).with(name: 'CAPTAIN_OPEN_AI_MODEL').and_return(mock_installation_config) | ||||||
|  |     allow(Captain::PromptRenderer).to receive(:render).and_return('rendered_template') | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#agent' do | ||||||
|  |     it 'creates an Agents::Agent with correct parameters' do | ||||||
|  |       expect(Agents::Agent).to receive(:new).with( | ||||||
|  |         name: 'Test Agent', | ||||||
|  |         instructions: instance_of(Proc), | ||||||
|  |         tools: [], | ||||||
|  |         model: 'gpt-4-turbo', | ||||||
|  |         temperature: 0.8, | ||||||
|  |         response_schema: Captain::ResponseSchema | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'converts nil temperature to 0.0' do | ||||||
|  |       dummy_instance.temperature = nil | ||||||
|  |  | ||||||
|  |       expect(Agents::Agent).to receive(:new).with( | ||||||
|  |         hash_including(temperature: 0.0) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'converts temperature to float' do | ||||||
|  |       dummy_instance.temperature = '0.5' | ||||||
|  |  | ||||||
|  |       expect(Agents::Agent).to receive(:new).with( | ||||||
|  |         hash_including(temperature: 0.5) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#agent_instructions' do | ||||||
|  |     it 'calls Captain::PromptRenderer with base context' do | ||||||
|  |       expect(Captain::PromptRenderer).to receive(:render).with( | ||||||
|  |         'dummy_class', | ||||||
|  |         hash_including(base_key: 'base_value') | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent_instructions | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'merges context state when provided' do | ||||||
|  |       context_double = instance_double(Agents::RunContext, | ||||||
|  |                                        context: { | ||||||
|  |                                          state: { | ||||||
|  |                                            conversation: { id: 123 }, | ||||||
|  |                                            contact: { name: 'John' } | ||||||
|  |                                          } | ||||||
|  |                                        }) | ||||||
|  |  | ||||||
|  |       expected_context = { | ||||||
|  |         base_key: 'base_value', | ||||||
|  |         conversation: { id: 123 }, | ||||||
|  |         contact: { name: 'John' } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       expect(Captain::PromptRenderer).to receive(:render).with( | ||||||
|  |         'dummy_class', | ||||||
|  |         hash_including(expected_context) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent_instructions(context_double) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'handles context without state' do | ||||||
|  |       context_double = instance_double(Agents::RunContext, context: {}) | ||||||
|  |  | ||||||
|  |       expect(Captain::PromptRenderer).to receive(:render).with( | ||||||
|  |         'dummy_class', | ||||||
|  |         hash_including( | ||||||
|  |           base_key: 'base_value', | ||||||
|  |           conversation: {}, | ||||||
|  |           contact: {} | ||||||
|  |         ) | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       dummy_instance.agent_instructions(context_double) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#template_name' do | ||||||
|  |     it 'returns underscored class name' do | ||||||
|  |       expect(dummy_instance.send(:template_name)).to eq('dummy_class') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#agent_tools' do | ||||||
|  |     it 'returns empty array by default' do | ||||||
|  |       expect(dummy_instance.send(:agent_tools)).to eq([]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#agent_model' do | ||||||
|  |     it 'returns value from InstallationConfig when present' do | ||||||
|  |       expect(dummy_instance.send(:agent_model)).to eq('gpt-4-turbo') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns default model when config not found' do | ||||||
|  |       allow(InstallationConfig).to receive(:find_by).and_return(nil) | ||||||
|  |  | ||||||
|  |       expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'returns default model when config value is nil' do | ||||||
|  |       allow(mock_installation_config).to receive(:value).and_return(nil) | ||||||
|  |  | ||||||
|  |       expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-mini') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#agent_response_schema' do | ||||||
|  |     it 'returns Captain::ResponseSchema' do | ||||||
|  |       expect(dummy_instance.send(:agent_response_schema)).to eq(Captain::ResponseSchema) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'required methods' do | ||||||
|  |     let(:incomplete_class) do | ||||||
|  |       Class.new do | ||||||
|  |         include Concerns::Agentable | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     let(:incomplete_instance) { incomplete_class.new } | ||||||
|  |  | ||||||
|  |     describe '#agent_name' do | ||||||
|  |       it 'raises NotImplementedError when not implemented' do | ||||||
|  |         expect { incomplete_instance.send(:agent_name) } | ||||||
|  |           .to raise_error(NotImplementedError, /must implement agent_name/) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     describe '#prompt_context' do | ||||||
|  |       it 'raises NotImplementedError when not implemented' do | ||||||
|  |         expect { incomplete_instance.send(:prompt_context) } | ||||||
|  |           .to raise_error(NotImplementedError, /must implement prompt_context/) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -0,0 +1,320 @@ | |||||||
|  | # frozen_string_literal: true | ||||||
|  |  | ||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe Captain::Assistant::AgentRunnerService do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:inbox) { create(:inbox, account: account) } | ||||||
|  |   let(:contact) { create(:contact, account: account) } | ||||||
|  |   let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } | ||||||
|  |   let(:assistant) { create(:captain_assistant, account: account) } | ||||||
|  |   let(:scenario) { create(:captain_scenario, assistant: assistant, enabled: true) } | ||||||
|  |  | ||||||
|  |   let(:mock_runner) { instance_double(Agents::Runner) } | ||||||
|  |   let(:mock_agent) { instance_double(Agents::Agent) } | ||||||
|  |   let(:mock_scenario_agent) { instance_double(Agents::Agent) } | ||||||
|  |   let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) } | ||||||
|  |  | ||||||
|  |   let(:message_history) do | ||||||
|  |     [ | ||||||
|  |       { role: 'user', content: 'Hello there' }, | ||||||
|  |       { role: 'assistant', content: 'Hi! How can I help you?', agent_name: 'Assistant' }, | ||||||
|  |       { role: 'user', content: 'I need help with my account' } | ||||||
|  |     ] | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   before do | ||||||
|  |     allow(assistant).to receive(:agent).and_return(mock_agent) | ||||||
|  |     scenarios_relation = instance_double(Captain::Scenario) | ||||||
|  |     allow(scenarios_relation).to receive(:enabled).and_return([scenario]) | ||||||
|  |     allow(assistant).to receive(:scenarios).and_return(scenarios_relation) | ||||||
|  |     allow(scenario).to receive(:agent).and_return(mock_scenario_agent) | ||||||
|  |     allow(Agents::Runner).to receive(:with_agents).and_return(mock_runner) | ||||||
|  |     allow(mock_runner).to receive(:run).and_return(mock_result) | ||||||
|  |     allow(mock_agent).to receive(:register_handoffs) | ||||||
|  |     allow(mock_scenario_agent).to receive(:register_handoffs) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#initialize' do | ||||||
|  |     it 'sets instance variables correctly' do | ||||||
|  |       service = described_class.new(assistant: assistant, conversation: conversation) | ||||||
|  |  | ||||||
|  |       expect(service.instance_variable_get(:@assistant)).to eq(assistant) | ||||||
|  |       expect(service.instance_variable_get(:@conversation)).to eq(conversation) | ||||||
|  |       expect(service.instance_variable_get(:@callbacks)).to eq({}) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'accepts callbacks parameter' do | ||||||
|  |       callbacks = { on_agent_thinking: proc { |x| x } } | ||||||
|  |       service = described_class.new(assistant: assistant, callbacks: callbacks) | ||||||
|  |  | ||||||
|  |       expect(service.instance_variable_get(:@callbacks)).to eq(callbacks) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#generate_response' do | ||||||
|  |     subject(:service) { described_class.new(assistant: assistant, conversation: conversation) } | ||||||
|  |  | ||||||
|  |     it 'builds agents and wires them together' do | ||||||
|  |       expect(assistant).to receive(:agent).and_return(mock_agent) | ||||||
|  |       scenarios_relation = instance_double(Captain::Scenario) | ||||||
|  |       allow(scenarios_relation).to receive(:enabled).and_return([scenario]) | ||||||
|  |       expect(assistant).to receive(:scenarios).and_return(scenarios_relation) | ||||||
|  |       expect(scenario).to receive(:agent).and_return(mock_scenario_agent) | ||||||
|  |       expect(mock_agent).to receive(:register_handoffs).with(mock_scenario_agent) | ||||||
|  |       expect(mock_scenario_agent).to receive(:register_handoffs).with(mock_agent) | ||||||
|  |  | ||||||
|  |       service.generate_response(message_history: message_history) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'creates runner with agents' do | ||||||
|  |       expect(Agents::Runner).to receive(:with_agents).with(mock_agent, mock_scenario_agent) | ||||||
|  |  | ||||||
|  |       service.generate_response(message_history: message_history) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'runs agent with extracted user message and context' do | ||||||
|  |       expected_context = { | ||||||
|  |         conversation_history: [ | ||||||
|  |           { role: :user, content: 'Hello there', agent_name: nil }, | ||||||
|  |           { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }, | ||||||
|  |           { role: :user, content: 'I need help with my account', agent_name: nil } | ||||||
|  |         ], | ||||||
|  |         state: hash_including( | ||||||
|  |           account_id: account.id, | ||||||
|  |           assistant_id: assistant.id, | ||||||
|  |           conversation: hash_including(id: conversation.id), | ||||||
|  |           contact: hash_including(id: contact.id) | ||||||
|  |         ) | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       expect(mock_runner).to receive(:run).with( | ||||||
|  |         'I need help with my account', | ||||||
|  |         context: expected_context | ||||||
|  |       ) | ||||||
|  |  | ||||||
|  |       service.generate_response(message_history: message_history) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'processes and formats agent result' do | ||||||
|  |       result = service.generate_response(message_history: message_history) | ||||||
|  |  | ||||||
|  |       expect(result).to eq({ 'response' => 'Test response' }) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when no scenarios are enabled' do | ||||||
|  |       before do | ||||||
|  |         scenarios_relation = instance_double(Captain::Scenario) | ||||||
|  |         allow(scenarios_relation).to receive(:enabled).and_return([]) | ||||||
|  |         allow(assistant).to receive(:scenarios).and_return(scenarios_relation) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'only uses assistant agent' do | ||||||
|  |         expect(Agents::Runner).to receive(:with_agents).with(mock_agent) | ||||||
|  |         expect(mock_agent).not_to receive(:register_handoffs) | ||||||
|  |  | ||||||
|  |         service.generate_response(message_history: message_history) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when agent result is a string' do | ||||||
|  |       let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') } | ||||||
|  |  | ||||||
|  |       it 'formats string response correctly' do | ||||||
|  |         result = service.generate_response(message_history: message_history) | ||||||
|  |  | ||||||
|  |         expect(result).to eq({ | ||||||
|  |                                'response' => 'Simple string response', | ||||||
|  |                                'reasoning' => 'Processed by agent' | ||||||
|  |                              }) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when an error occurs' do | ||||||
|  |       let(:error) { StandardError.new('Test error') } | ||||||
|  |  | ||||||
|  |       before do | ||||||
|  |         allow(mock_runner).to receive(:run).and_raise(error) | ||||||
|  |         allow(ChatwootExceptionTracker).to receive(:new).and_return( | ||||||
|  |           instance_double(ChatwootExceptionTracker, capture_exception: true) | ||||||
|  |         ) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'captures exception and returns error response' do | ||||||
|  |         expect(ChatwootExceptionTracker).to receive(:new).with(error, account: conversation.account) | ||||||
|  |  | ||||||
|  |         result = service.generate_response(message_history: message_history) | ||||||
|  |  | ||||||
|  |         expect(result).to eq({ | ||||||
|  |                                'response' => 'conversation_handoff', | ||||||
|  |                                'reasoning' => 'Error occurred: Test error' | ||||||
|  |                              }) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'logs error details' do | ||||||
|  |         expect(Rails.logger).to receive(:error).with('[Captain V2] AgentRunnerService error: Test error') | ||||||
|  |         expect(Rails.logger).to receive(:error).with(kind_of(String)) | ||||||
|  |  | ||||||
|  |         service.generate_response(message_history: message_history) | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       context 'when conversation is nil' do | ||||||
|  |         subject(:service) { described_class.new(assistant: assistant, conversation: nil) } | ||||||
|  |  | ||||||
|  |         it 'handles missing conversation gracefully' do | ||||||
|  |           expect(ChatwootExceptionTracker).to receive(:new).with(error, account: nil) | ||||||
|  |  | ||||||
|  |           result = service.generate_response(message_history: message_history) | ||||||
|  |  | ||||||
|  |           expect(result).to eq({ | ||||||
|  |                                  'response' => 'conversation_handoff', | ||||||
|  |                                  'reasoning' => 'Error occurred: Test error' | ||||||
|  |                                }) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#build_context' do | ||||||
|  |     subject(:service) { described_class.new(assistant: assistant, conversation: conversation) } | ||||||
|  |  | ||||||
|  |     it 'builds context with conversation history and state' do | ||||||
|  |       context = service.send(:build_context, message_history) | ||||||
|  |  | ||||||
|  |       expect(context).to include( | ||||||
|  |         conversation_history: array_including( | ||||||
|  |           { role: :user, content: 'Hello there', agent_name: nil }, | ||||||
|  |           { role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' } | ||||||
|  |         ), | ||||||
|  |         state: hash_including( | ||||||
|  |           account_id: account.id, | ||||||
|  |           assistant_id: assistant.id | ||||||
|  |         ) | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with multimodal content' do | ||||||
|  |       let(:multimodal_message_history) do | ||||||
|  |         [ | ||||||
|  |           { | ||||||
|  |             role: 'user', | ||||||
|  |             content: [ | ||||||
|  |               { type: 'text', text: 'Can you help with this image?' }, | ||||||
|  |               { type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } } | ||||||
|  |             ] | ||||||
|  |           } | ||||||
|  |         ] | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'extracts text content from multimodal messages' do | ||||||
|  |         context = service.send(:build_context, multimodal_message_history) | ||||||
|  |  | ||||||
|  |         expect(context[:conversation_history].first[:content]).to eq('Can you help with this image?') | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#extract_last_user_message' do | ||||||
|  |     subject(:service) { described_class.new(assistant: assistant, conversation: conversation) } | ||||||
|  |  | ||||||
|  |     it 'extracts the last user message' do | ||||||
|  |       result = service.send(:extract_last_user_message, message_history) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('I need help with my account') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#extract_text_from_content' do | ||||||
|  |     subject(:service) { described_class.new(assistant: assistant, conversation: conversation) } | ||||||
|  |  | ||||||
|  |     it 'extracts text from string content' do | ||||||
|  |       result = service.send(:extract_text_from_content, 'Simple text') | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Simple text') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'extracts response from hash content' do | ||||||
|  |       content = { 'response' => 'Hash response' } | ||||||
|  |       result = service.send(:extract_text_from_content, content) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('Hash response') | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'extracts text from multimodal array content' do | ||||||
|  |       content = [ | ||||||
|  |         { type: 'text', text: 'First part' }, | ||||||
|  |         { type: 'image_url', image_url: { url: 'image.jpg' } }, | ||||||
|  |         { type: 'text', text: 'Second part' } | ||||||
|  |       ] | ||||||
|  |  | ||||||
|  |       result = service.send(:extract_text_from_content, content) | ||||||
|  |  | ||||||
|  |       expect(result).to eq('First part Second part') | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe '#build_state' do | ||||||
|  |     subject(:service) { described_class.new(assistant: assistant, conversation: conversation) } | ||||||
|  |  | ||||||
|  |     it 'builds state with assistant and account information' do | ||||||
|  |       state = service.send(:build_state) | ||||||
|  |  | ||||||
|  |       expect(state).to include( | ||||||
|  |         account_id: account.id, | ||||||
|  |         assistant_id: assistant.id, | ||||||
|  |         assistant_config: assistant.config | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'includes conversation attributes when conversation is present' do | ||||||
|  |       state = service.send(:build_state) | ||||||
|  |  | ||||||
|  |       expect(state[:conversation]).to include( | ||||||
|  |         id: conversation.id, | ||||||
|  |         inbox_id: inbox.id, | ||||||
|  |         contact_id: contact.id, | ||||||
|  |         status: conversation.status | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'includes contact attributes when contact is present' do | ||||||
|  |       state = service.send(:build_state) | ||||||
|  |  | ||||||
|  |       expect(state[:contact]).to include( | ||||||
|  |         id: contact.id, | ||||||
|  |         name: contact.name, | ||||||
|  |         email: contact.email | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when conversation is nil' do | ||||||
|  |       subject(:service) { described_class.new(assistant: assistant, conversation: nil) } | ||||||
|  |  | ||||||
|  |       it 'builds state without conversation and contact' do | ||||||
|  |         state = service.send(:build_state) | ||||||
|  |  | ||||||
|  |         expect(state).to include( | ||||||
|  |           account_id: account.id, | ||||||
|  |           assistant_id: assistant.id, | ||||||
|  |           assistant_config: assistant.config | ||||||
|  |         ) | ||||||
|  |         expect(state).not_to have_key(:conversation) | ||||||
|  |         expect(state).not_to have_key(:contact) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'constants' do | ||||||
|  |     it 'defines conversation state attributes' do | ||||||
|  |       expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include( | ||||||
|  |         :id, :display_id, :inbox_id, :contact_id, :status, :priority | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     it 'defines contact state attributes' do | ||||||
|  |       expect(described_class::CONTACT_STATE_ATTRIBUTES).to include( | ||||||
|  |         :id, :name, :email, :phone_number, :identifier, :contact_type | ||||||
|  |       ) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
		Reference in New Issue
	
	Block a user
	 Shivam Mishra
					Shivam Mishra