require 'net/http' require 'uri' module Captain module Tools class ToolRunner def self.run(assistant:, tool_key:, inbox:, conversation:, additional_data: {}) new(assistant, tool_key, inbox, conversation, additional_data).run end def initialize(assistant, tool_key, inbox, conversation, additional_data) @assistant = assistant @tool_key = tool_key @inbox = inbox @conversation = conversation @definition = Captain::Tools::Definitions::ALL[tool_key] @config = Captain::ToolConfig.find_by(captain_assistant_id: assistant.id, tool_key: tool_key) || Captain::ToolConfig.find_by(inbox: inbox, account: inbox.account, tool_key: tool_key) @contact = conversation.contact @additional_data = additional_data end def run return failed_response('Tool not configured or disabled') unless @config&.is_enabled return failed_response('Tool definition not found') unless @definition start_time = Time.current result = case @definition[:type] when :http then execute_http when :webhook then execute_webhook when :internal then execute_internal else failed_response('Unknown tool type') end duration = (Time.current - start_time) * 1000 result.merge(duration_ms: duration) end private def execute_http uri = URI(@definition[:url]) http = Net::HTTP.new(uri.host, uri.port) http.use_ssl = uri.scheme == 'https' http.read_timeout = 8 request = Net::HTTP::Get.new(uri) request['PLUG-PLAY-ID'] = @config.plug_play_id request['PLUG-PLAY-TOKEN'] = @config.plug_play_token response = http.request(request) if response.is_a?(Net::HTTPSuccess) parsed_body = parse_response(response.body) { success: true, body: parsed_body, status: response.code } else { success: false, status: response.code, error: 'HTTP Request Failed' } end rescue StandardError => e { success: false, error: e.message } end def execute_internal tool_class_name = "Captain::Tools::#{@tool_key.camelize}Tool" # Safe constantize to avoid arbitrary code execution if key was untrusted (though it comes from Definitions) klass = tool_class_name.safe_constantize return failed_response("Tool Class #{tool_class_name} not found") unless klass tool_instance = klass.new(@assistant, user: nil, conversation: @conversation) # Merge additional data into params if the tool expects them # Typically internal tools take a params hash. # We assume tool_output from brain is passed as params or we build it here? # ToolRunner.run signature: additional_data: { message: ... } # The 'params' usuall come from the Brain's tool_input. # But ToolRunner is called with `tool_key`... wait. # AssistantChatService (line 48) calls ToolRunner with tool_key. # But WHERE are the arguments (e.g. suite="Stilo")? # A HA! JasmineBrain.decide returns `tool_key`. # But does it return parameters? # Looking at AssistantChatService again... # brain_decision has .tool_key. # It DOES NOT look like it passes parameters to ToolRunner! # CHECK THIS before committing logic. # Assumption based on `CheckAvailabilityTool` code: `execute(params = {})`. # Params need to come from somewhere. # In current AssistantChatService, `runner_result` is called without `tool_input`. # This implies tools must parse the message THEMSELVES or parameters are missing? # Let's verify AssistantChatService brain decision struct. # For now, implementing the basic invocation. `execute` in BaseTool often parses context if params are empty. # But CheckAvailabilityTool explicitly does `suite_category = params['suite']`. # If params are empty, it fails. # CRITICAL: Is JasmineBrain extracting parameters? # If not, the tool must extract them from @conversation. # But CheckAvailabilityTool uses `params`. # Let's verify `JasmineBrain` in a later step if needed. # For now, we pass `additional_data` as params which usually contains `message`. # But `CheckAvailabilityTool` expects 'suite'. # I will pass `additional_data` merged with any extracted parameters if available. # But since I can't change the inputs to ToolRunner right here easily without finding the caller... # I will assume the Tool executes with what it has. # Wait, BaseTool has access to @conversation. # CheckAvailabilityTool reads `params['suite']`. # If JasmineBrain doesn't extract 'suite', this fails. # RE-READING `GeneratePixTool` (Step 8838 modified): # "Refactored... to operate on an active draft reservation, removing direct parameter requirements". # RE-READING `CreateReservationIntentTool` (Step 8868): # `suite_category = params['suite']`. It NEEDS params! # RE-READING `AssistantChatService` (Step 8898): # `brain_decision = Captain::Llm::JasmineBrain.decide(...)`. # `ToolRunner.run(..., additional_data: { message: additional_data })`. # Does `JasmineBrain` decision contain parameters? # `brain_decision.tool_key`. # If `JasmineBrain` is an LLM call providing JSON, it usually provides arguments. # But `AssistantChatService` doesn't seem to pass them to `ToolRunner`. # This might be ANOTHER bug. # But first, let's enable the execution. CheckAvailabilityTool might parse the last message if params are empty? # No, it checks `params['suite']`. # I will inject a "Smart Parameter Extraction" if params are missing? # OR: I assume `additional_data` CONTAINS the params? # `AssistantChatService` passes `additional_data: { message: additional_message }`. # That's just the message string. # Warning: `CreateReservationIntentTool` will fail if it receives empty params. # But `ToolRunner` must support it first. # I will implement the execution. # If I see "Missing suite" in the logs, I know `AssistantChatService` needs to pass parameters. execution_result = tool_instance.execute(@additional_data.with_indifferent_access) # Normalize result. Tool execute usually returns a String or Hash. if execution_result.is_a?(String) { success: true, body: { message: execution_result } } else { success: true, body: execution_result } end rescue StandardError => e { success: false, error: e.message } end def execute_webhook url = @config.webhook_url return failed_response('Webhook URL missing') if url.blank? payload = build_webhook_payload # Specific logic for escalate_human if @tool_key == 'escalar_humano' # Label logic handles in brain/response service if success end response = RestClient::Request.execute( method: :post, url: url, payload: payload.to_json, headers: { content_type: :json, accept: :json }, timeout: 8 ) { success: true, status: response.code, body: { message: 'Webhook sent' } } rescue StandardError => e { success: false, error: e.message } end def build_webhook_payload base = { conversation_id: @conversation.id, contact_id: @contact.id, event: @tool_key } # Specific payloads if @tool_key == 'maria_fotos' base.merge!( suite_category: extract_suite_category || 'Indefinida', message: 'User requested photos' ) elsif @tool_key == 'escalar_humano' base.merge!(reason: 'User requested human agent') end base end def extract_suite_category # Simple extraction or ask JasmineBrain to help later? # For V1, we try to grab from message content or nil msg = @additional_data[:message]&.downcase || '' return 'Alexa' if msg.include?('alexa') return 'Stilo' if msg.include?('stilo') return 'Hidromassagem' if msg.include?('hidro') nil end def parse_response(body) case @tool_key when 'status_suites' Captain::Tools::Parsers::StatusSuitesParser.parse(body) else begin JSON.parse(body) rescue StandardError body end end end def failed_response(msg) { success: false, error: msg } end end end end