chatwoot-develop/enterprise/app/services/captain/tools/tool_runner.rb
2026-01-15 00:28:54 -03:00

274 lines
10 KiB
Ruby

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 = resolve_definition
@config = resolve_config
@contact = conversation.contact
@additional_data = additional_data
end
def run
return failed_response('Tool not configured or disabled') unless tool_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
when :scenario then execute_scenario
else failed_response('Unknown tool type')
end
duration = (Time.current - start_time) * 1000
result.merge(duration_ms: duration)
end
private
def resolve_definition
return Captain::Tools::Definitions::ALL[@tool_key] if Captain::Tools::Definitions::ALL.key?(@tool_key)
scenario = find_scenario_by_tool_key
return { type: :scenario, scenario: scenario } if scenario
nil
end
def resolve_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)
end
def find_scenario_by_tool_key
@assistant.scenarios.enabled.find do |scenario|
"consultar_#{scenario.title.parameterize.underscore}" == @tool_key
end
end
def tool_enabled?
return true if @definition && @definition[:type] == :scenario
@config&.is_enabled
end
def execute_scenario
scenario = @definition[:scenario]
# We pass the contact (user) and conversation context.
# We use the full user message as the 'pergunta_interna' for the sub-agent.
tool = Captain::Tools::ScenarioDelegatorTool.new(scenario, user: @contact, conversation: @conversation)
# ScenarioDelegatorTool expects 'pergunta_interna'
params = { pergunta_interna: @additional_data[:message] }
execution_result = tool.execute(params)
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: "Scenario Error: #{e.message}" }
end
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: @contact, 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