From a8aefa0c73d3f7d830b9f1525cef61a31b75e9ee Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 6 Oct 2025 13:00:11 +0530 Subject: [PATCH 1/8] chore: Add account health missing translations (#12596) # Pull Request Template ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes # (issue) ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/dashboard/i18n/locale/en/inboxMgmt.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 60038253c..87fe57564 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -648,14 +648,16 @@ "TIER_1K": "1K customers per 24h", "TIER_10K": "10K customers per 24h", "TIER_100K": "100K customers per 24h", - "TIER_UNLIMITED": "Unlimited customers per 24h" + "TIER_UNLIMITED": "Unlimited customers per 24h", + "UNKNOWN": "Rating not available" }, "STATUSES": { "APPROVED": "Approved", "PENDING_REVIEW": "Pending Review", "AVAILABLE_WITHOUT_REVIEW": "Available Without Review", "REJECTED": "Rejected", - "DECLINED": "Declined" + "DECLINED": "Declined", + "NON_EXISTS": "Non exists" }, "MODES": { "SANDBOX": "Sandbox", From 8bbb8ba5a4495b848dae7ce3afcef412f5fb4861 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 6 Oct 2025 20:23:15 +0530 Subject: [PATCH 2/8] feat(ee): Captain custom http tools (#12584) To test this out, use the following PR: https://github.com/chatwoot/chatwoot/pull/12585 --------- Co-authored-by: Pranav --- ...51003091242_create_captain_custom_tools.rb | 22 + db/schema.rb | 21 +- .../accounts/captain/assistants_controller.rb | 3 +- enterprise/app/models/captain/assistant.rb | 13 + enterprise/app/models/captain/custom_tool.rb | 91 +++++ enterprise/app/models/captain/scenario.rb | 20 +- .../models/concerns/captain_tools_helpers.rb | 16 +- .../concerns/safe_endpoint_validatable.rb | 84 ++++ enterprise/app/models/concerns/toolable.rb | 78 ++++ .../app/models/enterprise/concerns/account.rb | 1 + enterprise/lib/captain/tools/http_tool.rb | 105 +++++ lib/tasks/captain_chat.rake | 2 +- .../lib/captain/tools/http_tool_spec.rb | 241 +++++++++++ .../models/captain/custom_tool_spec.rb | 386 ++++++++++++++++++ .../models/captain/scenario_spec.rb | 183 ++++++++- .../concerns/captain_tools_helpers_spec.rb | 74 ---- spec/factories/captain/custom_tool.rb | 51 +++ 17 files changed, 1299 insertions(+), 92 deletions(-) create mode 100644 db/migrate/20251003091242_create_captain_custom_tools.rb create mode 100644 enterprise/app/models/captain/custom_tool.rb create mode 100644 enterprise/app/models/concerns/safe_endpoint_validatable.rb create mode 100644 enterprise/app/models/concerns/toolable.rb create mode 100644 enterprise/lib/captain/tools/http_tool.rb create mode 100644 spec/enterprise/lib/captain/tools/http_tool_spec.rb create mode 100644 spec/enterprise/models/captain/custom_tool_spec.rb create mode 100644 spec/factories/captain/custom_tool.rb diff --git a/db/migrate/20251003091242_create_captain_custom_tools.rb b/db/migrate/20251003091242_create_captain_custom_tools.rb new file mode 100644 index 000000000..8f63d826e --- /dev/null +++ b/db/migrate/20251003091242_create_captain_custom_tools.rb @@ -0,0 +1,22 @@ +class CreateCaptainCustomTools < ActiveRecord::Migration[7.1] + def change + create_table :captain_custom_tools do |t| + t.references :account, null: false, index: true + t.string :slug, null: false + t.string :title, null: false + t.text :description + t.string :http_method, null: false, default: 'GET' + t.text :endpoint_url, null: false + t.text :request_template + t.text :response_template + t.string :auth_type, default: 'none' + t.jsonb :auth_config, default: {} + t.jsonb :param_schema, default: [] + t.boolean :enabled, default: true, null: false + + t.timestamps + end + + add_index :captain_custom_tools, [:account_id, :slug], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index d5f0c244c..f31d05cc3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do +ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do t.index ["account_id"], name: "index_captain_assistants_on_account_id" end + create_table "captain_custom_tools", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "slug", null: false + t.string "title", null: false + t.text "description" + t.string "http_method", default: "GET", null: false + t.text "endpoint_url", null: false + t.text "request_template" + t.text "response_template" + t.string "auth_type", default: "none" + t.jsonb "auth_config", default: {} + t.jsonb "param_schema", default: [] + t.boolean "enabled", default: true, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true + t.index ["account_id"], name: "index_captain_custom_tools_on_account_id" + end + create_table "captain_documents", force: :cascade do |t| t.string "name" t.string "external_link", null: false diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index 21675bad0..ebeaaf67f 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base end def tools - @tools = Captain::Assistant.available_agent_tools + assistant = Captain::Assistant.new(account: Current.account) + @tools = assistant.available_agent_tools end private diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 0423abf67..21ecf05c4 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord name end + def available_agent_tools + tools = self.class.built_in_agent_tools.dup + + custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata) + tools.concat(custom_tools) + + tools + end + + def available_tool_ids + available_agent_tools.pluck(:id) + end + def push_event_data { id: id, diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb new file mode 100644 index 000000000..8ad02f401 --- /dev/null +++ b/enterprise/app/models/captain/custom_tool.rb @@ -0,0 +1,91 @@ +# == Schema Information +# +# Table name: captain_custom_tools +# +# id :bigint not null, primary key +# auth_config :jsonb +# auth_type :string default("none") +# description :text +# enabled :boolean default(TRUE), not null +# endpoint_url :text not null +# http_method :string default("GET"), not null +# param_schema :jsonb +# request_template :text +# response_template :text +# slug :string not null +# title :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# +# Indexes +# +# index_captain_custom_tools_on_account_id (account_id) +# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE +# +class Captain::CustomTool < ApplicationRecord + include Concerns::Toolable + include Concerns::SafeEndpointValidatable + + self.table_name = 'captain_custom_tools' + + PARAM_SCHEMA_VALIDATION = { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'name': { 'type': 'string' }, + 'type': { 'type': 'string' }, + 'description': { 'type': 'string' }, + 'required': { 'type': 'boolean' } + }, + 'required': %w[name type description], + 'additionalProperties': false + } + }.to_json.freeze + + belongs_to :account + + enum :http_method, %w[GET POST].index_by(&:itself), validate: true + enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth + + before_validation :generate_slug + + validates :slug, presence: true, uniqueness: { scope: :account_id } + validates :title, presence: true + validates :endpoint_url, presence: true + validates_with JsonSchemaValidator, + schema: PARAM_SCHEMA_VALIDATION, + attribute_resolver: ->(record) { record.param_schema } + + scope :enabled, -> { where(enabled: true) } + + def to_tool_metadata + { + id: slug, + title: title, + description: description, + custom: true + } + end + + private + + def generate_slug + return if slug.present? + + base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}" + self.slug = find_unique_slug(base_slug) + end + + def find_unique_slug(base_slug, counter = 0) + slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}" + return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate) + + slug_candidate + end + + def slug_exists?(candidate) + self.class.exists?(account_id: account_id, slug: candidate) + end +end diff --git a/enterprise/app/models/captain/scenario.rb b/enterprise/app/models/captain/scenario.rb index aac7e2411..d04990199 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -57,7 +57,7 @@ class Captain::Scenario < ApplicationRecord end def agent_tools - resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) } + resolved_tools.map { |tool| resolve_tool_instance(tool) } end def resolved_instructions @@ -69,12 +69,24 @@ class Captain::Scenario < ApplicationRecord def resolved_tools return [] if tools.blank? - available_tools = self.class.available_agent_tools + available_tools = assistant.available_agent_tools tools.filter_map do |tool_id| available_tools.find { |tool| tool[:id] == tool_id } end end + def resolve_tool_instance(tool_metadata) + tool_id = tool_metadata[:id] + + if tool_metadata[:custom] + custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true) + custom_tool&.tool(assistant) + else + tool_class = self.class.resolve_tool_class(tool_id) + tool_class&.new(assistant) + end + end + # Validates that all tool references in the instruction are valid. # Parses the instruction for tool references and checks if they exist # in the available tools configuration. @@ -95,8 +107,8 @@ class Captain::Scenario < ApplicationRecord tool_ids = extract_tool_ids_from_text(instruction) return if tool_ids.empty? - available_tool_ids = self.class.available_tool_ids - invalid_tools = tool_ids - available_tool_ids + all_available_tool_ids = assistant.available_tool_ids + invalid_tools = tool_ids - all_available_tool_ids return unless invalid_tools.any? diff --git a/enterprise/app/models/concerns/captain_tools_helpers.rb b/enterprise/app/models/concerns/captain_tools_helpers.rb index 5a660310c..34133aac2 100644 --- a/enterprise/app/models/concerns/captain_tools_helpers.rb +++ b/enterprise/app/models/concerns/captain_tools_helpers.rb @@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} class_methods do - # Returns all available agent tools with their metadata. + # Returns all built-in agent tools with their metadata. # Only includes tools that have corresponding class files and can be resolved. # # @return [Array] Array of tool hashes with :id, :title, :description, :icon - def available_agent_tools - @available_agent_tools ||= load_agent_tools + def built_in_agent_tools + @built_in_agent_tools ||= load_agent_tools end # Resolves a tool class from a tool ID. @@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers class_name.safe_constantize end - # Returns an array of all available tool IDs. - # Convenience method that extracts just the IDs from available_agent_tools. + # Returns an array of all built-in tool IDs. + # Convenience method that extracts just the IDs from built_in_agent_tools. # - # @return [Array] Array of available tool IDs - def available_tool_ids - @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] } + # @return [Array] Array of built-in tool IDs + def built_in_tool_ids + @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] } end private diff --git a/enterprise/app/models/concerns/safe_endpoint_validatable.rb b/enterprise/app/models/concerns/safe_endpoint_validatable.rb new file mode 100644 index 000000000..b151b10e7 --- /dev/null +++ b/enterprise/app/models/concerns/safe_endpoint_validatable.rb @@ -0,0 +1,84 @@ +module Concerns::SafeEndpointValidatable + extend ActiveSupport::Concern + + FRONTEND_HOST = URI.parse(ENV.fetch('FRONTEND_URL', 'http://localhost:3000')).host.freeze + DISALLOWED_HOSTS = ['localhost', /\.local\z/i].freeze + + included do + validate :validate_safe_endpoint_url + end + + private + + def validate_safe_endpoint_url + return if endpoint_url.blank? + + uri = parse_endpoint_uri + return errors.add(:endpoint_url, 'must be a valid URL') unless uri + + validate_endpoint_scheme(uri) + validate_endpoint_host(uri) + validate_not_ip_address(uri) + validate_no_unicode_chars(uri) + end + + def parse_endpoint_uri + # Strip Liquid template syntax for validation + # Replace {{ variable }} with a placeholder value + sanitized_url = endpoint_url.gsub(/\{\{[^}]+\}\}/, 'placeholder') + URI.parse(sanitized_url) + rescue URI::InvalidURIError + nil + end + + def validate_endpoint_scheme(uri) + return if uri.scheme == 'https' + + errors.add(:endpoint_url, 'must use HTTPS protocol') + end + + def validate_endpoint_host(uri) + if uri.host.blank? + errors.add(:endpoint_url, 'must have a valid hostname') + return + end + + if uri.host == FRONTEND_HOST + errors.add(:endpoint_url, 'cannot point to the application itself') + return + end + + DISALLOWED_HOSTS.each do |pattern| + matched = if pattern.is_a?(Regexp) + uri.host =~ pattern + else + uri.host.downcase == pattern + end + + next unless matched + + errors.add(:endpoint_url, 'cannot use disallowed hostname') + break + end + end + + def validate_not_ip_address(uri) + # Check for IPv4 + if /\A\d+\.\d+\.\d+\.\d+\z/.match?(uri.host) + errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname') + return + end + + # Check for IPv6 + return unless uri.host.include?(':') + + errors.add(:endpoint_url, 'cannot be an IP address, must be a hostname') + end + + def validate_no_unicode_chars(uri) + return unless uri.host + return if /\A[\x00-\x7F]+\z/.match?(uri.host) + + errors.add(:endpoint_url, 'hostname cannot contain non-ASCII characters') + end +end diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb new file mode 100644 index 000000000..24c16c6f4 --- /dev/null +++ b/enterprise/app/models/concerns/toolable.rb @@ -0,0 +1,78 @@ +module Concerns::Toolable + extend ActiveSupport::Concern + + def tool(assistant) + custom_tool_record = self + + tool_class = Class.new(Captain::Tools::HttpTool) do + description custom_tool_record.description + + custom_tool_record.param_schema.each do |param_def| + param param_def['name'].to_sym, + type: param_def['type'], + desc: param_def['description'], + required: param_def.fetch('required', true) + end + end + + tool_class.new(assistant, self) + end + + def build_request_url(params) + return endpoint_url if endpoint_url.blank? || endpoint_url.exclude?('{{') + + render_template(endpoint_url, params) + end + + def build_request_body(params) + return nil if request_template.blank? + + render_template(request_template, params) + end + + def build_auth_headers + return {} if auth_none? + + case auth_type + when 'bearer' + { 'Authorization' => "Bearer #{auth_config['token']}" } + when 'api_key' + if auth_config['location'] == 'header' + { auth_config['name'] => auth_config['key'] } + else + {} + end + else + {} + end + end + + def build_basic_auth_credentials + return nil unless auth_type == 'basic' + + [auth_config['username'], auth_config['password']] + end + + def format_response(raw_response_body) + return raw_response_body if response_template.blank? + + response_data = parse_response_body(raw_response_body) + render_template(response_template, { 'response' => response_data }) + end + + private + + def render_template(template, context) + liquid_template = Liquid::Template.parse(template, error_mode: :strict) + liquid_template.render(context.deep_stringify_keys, registers: {}, strict_variables: true, strict_filters: true) + rescue Liquid::SyntaxError, Liquid::UndefinedVariable, Liquid::UndefinedFilter => e + Rails.logger.error("Liquid template error: #{e.message}") + raise "Template rendering failed: #{e.message}" + end + + def parse_response_body(body) + JSON.parse(body) + rescue JSON::ParserError + body + end +end diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index b52ac4b3e..b82d84b0a 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -10,6 +10,7 @@ module Enterprise::Concerns::Account has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' + has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' has_many :copilot_threads, dependent: :destroy_async has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb new file mode 100644 index 000000000..b634de04e --- /dev/null +++ b/enterprise/lib/captain/tools/http_tool.rb @@ -0,0 +1,105 @@ +require 'agents' + +class Captain::Tools::HttpTool < Agents::Tool + def initialize(assistant, custom_tool) + @assistant = assistant + @custom_tool = custom_tool + super() + end + + def active? + @custom_tool.enabled? + end + + def perform(_tool_context, **params) + url = @custom_tool.build_request_url(params) + body = @custom_tool.build_request_body(params) + + response = execute_http_request(url, body) + @custom_tool.format_response(response.body) + rescue StandardError => e + Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") + 'An error occurred while executing the request' + end + + private + + PRIVATE_IP_RANGES = [ + IPAddr.new('127.0.0.0/8'), # IPv4 Loopback + IPAddr.new('10.0.0.0/8'), # IPv4 Private network + IPAddr.new('172.16.0.0/12'), # IPv4 Private network + IPAddr.new('192.168.0.0/16'), # IPv4 Private network + IPAddr.new('169.254.0.0/16'), # IPv4 Link-local + IPAddr.new('::1'), # IPv6 Loopback + IPAddr.new('fc00::/7'), # IPv6 Unique local addresses + IPAddr.new('fe80::/10') # IPv6 Link-local + ].freeze + + # Limit response size to prevent memory exhaustion and match LLM token limits + # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows + MAX_RESPONSE_SIZE = 1.megabyte + + def execute_http_request(url, body) + uri = URI.parse(url) + + # Check if resolved IP is private + check_private_ip!(uri.host) + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == 'https' + http.read_timeout = 30 + http.open_timeout = 10 + http.max_retries = 0 # Disable redirects + + request = build_http_request(uri, body) + apply_authentication(request) + + response = http.request(request) + + raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess) + + validate_response!(response) + + response + end + + def check_private_ip!(hostname) + ip_address = IPAddr.new(Resolv.getaddress(hostname)) + + raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) } + rescue Resolv::ResolvError, SocketError => e + raise "DNS resolution failed: #{e.message}" + end + + def validate_response!(response) + content_length = response['content-length']&.to_i + if content_length && content_length > MAX_RESPONSE_SIZE + raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" + end + + return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE + + raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes" + end + + def build_http_request(uri, body) + if @custom_tool.http_method == 'POST' + request = Net::HTTP::Post.new(uri.request_uri) + if body + request.body = body + request['Content-Type'] = 'application/json' + end + else + request = Net::HTTP::Get.new(uri.request_uri) + end + request + end + + def apply_authentication(request) + headers = @custom_tool.build_auth_headers + headers.each { |key, value| request[key] = value } + + credentials = @custom_tool.build_basic_auth_credentials + request.basic_auth(*credentials) if credentials + end +end diff --git a/lib/tasks/captain_chat.rake b/lib/tasks/captain_chat.rake index cfe257196..6dfb37211 100644 --- a/lib/tasks/captain_chat.rake +++ b/lib/tasks/captain_chat.rake @@ -118,7 +118,7 @@ class CaptainChatSession end def show_available_tools - available_tools = Captain::Assistant.available_tool_ids + available_tools = @assistant.available_tool_ids if available_tools.any? puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}" else diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb new file mode 100644 index 000000000..d48af2752 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -0,0 +1,241 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::HttpTool, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:custom_tool) { create(:captain_custom_tool, account: account) } + let(:tool) { described_class.new(assistant, custom_tool) } + let(:tool_context) { Struct.new(:state).new({}) } + + describe '#active?' do + it 'returns true when custom tool is enabled' do + custom_tool.update!(enabled: true) + + expect(tool.active?).to be true + end + + it 'returns false when custom tool is disabled' do + custom_tool.update!(enabled: false) + + expect(tool.active?).to be false + end + end + + describe '#perform' do + context 'with GET request' do + before do + custom_tool.update!( + http_method: 'GET', + endpoint_url: 'https://example.com/orders/123', + response_template: nil + ) + stub_request(:get, 'https://example.com/orders/123') + .to_return(status: 200, body: '{"status": "success"}') + end + + it 'executes GET request and returns response body' do + result = tool.perform(tool_context) + + expect(result).to eq('{"status": "success"}') + expect(WebMock).to have_requested(:get, 'https://example.com/orders/123') + end + end + + context 'with POST request' do + before do + custom_tool.update!( + http_method: 'POST', + endpoint_url: 'https://example.com/orders', + request_template: '{"order_id": "{{ order_id }}"}', + response_template: nil + ) + stub_request(:post, 'https://example.com/orders') + .with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: '{"created": true}') + end + + it 'executes POST request with rendered body' do + result = tool.perform(tool_context, order_id: '123') + + expect(result).to eq('{"created": true}') + expect(WebMock).to have_requested(:post, 'https://example.com/orders') + .with(body: '{"order_id": "123"}') + end + end + + context 'with template variables in URL' do + before do + custom_tool.update!( + endpoint_url: 'https://example.com/orders/{{ order_id }}', + response_template: nil + ) + stub_request(:get, 'https://example.com/orders/456') + .to_return(status: 200, body: '{"order_id": "456"}') + end + + it 'renders URL template with params' do + result = tool.perform(tool_context, order_id: '456') + + expect(result).to eq('{"order_id": "456"}') + expect(WebMock).to have_requested(:get, 'https://example.com/orders/456') + end + end + + context 'with bearer token authentication' do + before do + custom_tool.update!( + auth_type: 'bearer', + auth_config: { 'token' => 'secret_bearer_token' }, + endpoint_url: 'https://example.com/data', + response_template: nil + ) + stub_request(:get, 'https://example.com/data') + .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' }) + .to_return(status: 200, body: '{"authenticated": true}') + end + + it 'adds Authorization header with bearer token' do + result = tool.perform(tool_context) + + expect(result).to eq('{"authenticated": true}') + expect(WebMock).to have_requested(:get, 'https://example.com/data') + .with(headers: { 'Authorization' => 'Bearer secret_bearer_token' }) + end + end + + context 'with basic authentication' do + before do + custom_tool.update!( + auth_type: 'basic', + auth_config: { 'username' => 'user123', 'password' => 'pass456' }, + endpoint_url: 'https://example.com/data', + response_template: nil + ) + stub_request(:get, 'https://example.com/data') + .with(basic_auth: %w[user123 pass456]) + .to_return(status: 200, body: '{"authenticated": true}') + end + + it 'adds basic auth credentials' do + result = tool.perform(tool_context) + + expect(result).to eq('{"authenticated": true}') + expect(WebMock).to have_requested(:get, 'https://example.com/data') + .with(basic_auth: %w[user123 pass456]) + end + end + + context 'with API key authentication' do + before do + custom_tool.update!( + auth_type: 'api_key', + auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' }, + endpoint_url: 'https://example.com/data', + response_template: nil + ) + stub_request(:get, 'https://example.com/data') + .with(headers: { 'X-API-Key' => 'api_key_123' }) + .to_return(status: 200, body: '{"authenticated": true}') + end + + it 'adds API key header' do + result = tool.perform(tool_context) + + expect(result).to eq('{"authenticated": true}') + expect(WebMock).to have_requested(:get, 'https://example.com/data') + .with(headers: { 'X-API-Key' => 'api_key_123' }) + end + end + + context 'with response template' do + before do + custom_tool.update!( + endpoint_url: 'https://example.com/orders/123', + response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}' + ) + stub_request(:get, 'https://example.com/orders/123') + .to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}') + end + + it 'formats response using template' do + result = tool.perform(tool_context) + + expect(result).to eq('Order status: shipped, ID: 123') + end + end + + context 'when handling errors' do + it 'returns generic error message on network failure' do + custom_tool.update!(endpoint_url: 'https://example.com/data') + stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect')) + + result = tool.perform(tool_context) + + expect(result).to eq('An error occurred while executing the request') + end + + it 'returns generic error message on timeout' do + custom_tool.update!(endpoint_url: 'https://example.com/data') + stub_request(:get, 'https://example.com/data').to_timeout + + result = tool.perform(tool_context) + + expect(result).to eq('An error occurred while executing the request') + end + + it 'returns generic error message on HTTP 404' do + custom_tool.update!(endpoint_url: 'https://example.com/data') + stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found') + + result = tool.perform(tool_context) + + expect(result).to eq('An error occurred while executing the request') + end + + it 'returns generic error message on HTTP 500' do + custom_tool.update!(endpoint_url: 'https://example.com/data') + stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error') + + result = tool.perform(tool_context) + + expect(result).to eq('An error occurred while executing the request') + end + + it 'logs error details' do + custom_tool.update!(endpoint_url: 'https://example.com/data') + stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error')) + + expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/) + + tool.perform(tool_context) + end + end + + context 'when integrating with Toolable methods' do + it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do + custom_tool.update!( + http_method: 'POST', + endpoint_url: 'https://example.com/users/{{ user_id }}/orders', + request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}', + auth_type: 'bearer', + auth_config: { 'token' => 'integration_token' }, + response_template: 'Created order #{{ response.order_number }} for {{ response.product }}' + ) + + stub_request(:post, 'https://example.com/users/42/orders') + .with( + body: '{"product": "Widget", "quantity": 5}', + headers: { + 'Authorization' => 'Bearer integration_token', + 'Content-Type' => 'application/json' + } + ) + .to_return(status: 200, body: '{"order_number": "ORD-789", "product": "Widget"}') + + result = tool.perform(tool_context, user_id: '42', product: 'Widget', quantity: 5) + + expect(result).to eq('Created order #ORD-789 for Widget') + end + end + end +end diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb new file mode 100644 index 000000000..c7c0451b1 --- /dev/null +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -0,0 +1,386 @@ +require 'rails_helper' + +RSpec.describe Captain::CustomTool, type: :model do + describe 'associations' do + it { is_expected.to belong_to(:account) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_presence_of(:endpoint_url) } + it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) } + + it { + expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic', + 'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth) + } + + describe 'slug uniqueness' do + let(:account) { create(:account) } + + it 'validates uniqueness of slug scoped to account' do + create(:captain_custom_tool, account: account, slug: 'custom_test-tool') + duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:slug]).to include('has already been taken') + end + + it 'allows same slug across different accounts' do + account2 = create(:account) + create(:captain_custom_tool, account: account, slug: 'custom_test-tool') + different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool') + + expect(different_account_tool).to be_valid + end + end + + describe 'param_schema validation' do + let(:account) { create(:account) } + + it 'is valid with proper param_schema' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true } + ]) + + expect(tool).to be_valid + end + + it 'is valid with empty param_schema' do + tool = build(:captain_custom_tool, account: account, param_schema: []) + + expect(tool).to be_valid + end + + it 'is invalid when param_schema is missing name' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'type' => 'string', 'description' => 'Order ID' } + ]) + + expect(tool).not_to be_valid + end + + it 'is invalid when param_schema is missing type' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'name' => 'order_id', 'description' => 'Order ID' } + ]) + + expect(tool).not_to be_valid + end + + it 'is invalid when param_schema is missing description' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'name' => 'order_id', 'type' => 'string' } + ]) + + expect(tool).not_to be_valid + end + + it 'is invalid with additional properties in param_schema' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' } + ]) + + expect(tool).not_to be_valid + end + + it 'is valid when required field is omitted (defaults to optional param)' do + tool = build(:captain_custom_tool, account: account, param_schema: [ + { 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' } + ]) + + expect(tool).to be_valid + end + end + end + + describe 'scopes' do + let(:account) { create(:account) } + + describe '.enabled' do + it 'returns only enabled custom tools' do + enabled_tool = create(:captain_custom_tool, account: account, enabled: true) + disabled_tool = create(:captain_custom_tool, account: account, enabled: false) + + expect(described_class.enabled).to include(enabled_tool) + expect(described_class.enabled).not_to include(disabled_tool) + end + end + end + + describe 'slug generation' do + let(:account) { create(:account) } + + it 'generates slug from title on creation' do + tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status') + + expect(tool.slug).to eq('custom_fetch-order-status') + end + + it 'adds custom_ prefix to generated slug' do + tool = create(:captain_custom_tool, account: account, title: 'My Tool') + + expect(tool.slug).to start_with('custom_') + end + + it 'does not override manually set slug' do + tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug') + + expect(tool.slug).to eq('custom_manual-slug') + end + + it 'handles slug collisions by appending counter' do + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') + tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool') + + expect(tool2.slug).to eq('custom_test-tool-1') + end + + it 'handles multiple slug collisions' do + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool') + create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1') + tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') + + expect(tool3.slug).to eq('custom_test-tool-2') + end + + it 'generates slug with UUID when title is blank' do + tool = build(:captain_custom_tool, account: account, title: nil) + tool.valid? + + expect(tool.slug).to match(/^custom_[0-9a-f-]+$/) + end + + it 'parameterizes title correctly' do + tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!') + + expect(tool.slug).to eq('custom_fetch-order-status-details') + end + end + + describe 'factory' do + it 'creates a valid custom tool with default attributes' do + tool = create(:captain_custom_tool) + + expect(tool).to be_valid + expect(tool.title).to be_present + expect(tool.slug).to be_present + expect(tool.endpoint_url).to be_present + expect(tool.http_method).to eq('GET') + expect(tool.auth_type).to eq('none') + expect(tool.enabled).to be true + end + + it 'creates valid tool with POST trait' do + tool = create(:captain_custom_tool, :with_post) + + expect(tool.http_method).to eq('POST') + expect(tool.request_template).to be_present + end + + it 'creates valid tool with bearer auth trait' do + tool = create(:captain_custom_tool, :with_bearer_auth) + + expect(tool.auth_type).to eq('bearer') + expect(tool.auth_config['token']).to eq('test_bearer_token_123') + end + + it 'creates valid tool with basic auth trait' do + tool = create(:captain_custom_tool, :with_basic_auth) + + expect(tool.auth_type).to eq('basic') + expect(tool.auth_config['username']).to eq('test_user') + expect(tool.auth_config['password']).to eq('test_pass') + end + + it 'creates valid tool with api key trait' do + tool = create(:captain_custom_tool, :with_api_key) + + expect(tool.auth_type).to eq('api_key') + expect(tool.auth_config['key']).to eq('test_api_key') + expect(tool.auth_config['location']).to eq('header') + end + end + + describe 'Toolable concern' do + let(:account) { create(:account) } + + describe '#build_request_url' do + it 'returns static URL when no template variables present' do + tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders') + + expect(tool.build_request_url({})).to eq('https://api.example.com/orders') + end + + it 'renders URL template with params' do + tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}') + + expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345') + end + + it 'handles multiple template variables' do + tool = create(:captain_custom_tool, account: account, + endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}') + + result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' }) + expect(result).to eq('https://api.example.com/orders/123?details=true') + end + end + + describe '#build_request_body' do + it 'returns nil when request_template is blank' do + tool = create(:captain_custom_tool, account: account, request_template: nil) + + expect(tool.build_request_body({})).to be_nil + end + + it 'renders request body template with params' do + tool = create(:captain_custom_tool, account: account, + request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }') + + result = tool.build_request_body({ order_id: '12345' }) + expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }') + end + end + + describe '#build_auth_headers' do + it 'returns empty hash for none auth type' do + tool = create(:captain_custom_tool, account: account, auth_type: 'none') + + expect(tool.build_auth_headers).to eq({}) + end + + it 'returns bearer token header' do + tool = create(:captain_custom_tool, :with_bearer_auth, account: account) + + expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' }) + end + + it 'returns API key header when location is header' do + tool = create(:captain_custom_tool, :with_api_key, account: account) + + expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' }) + end + + it 'returns empty hash for API key when location is not header' do + tool = create(:captain_custom_tool, account: account, auth_type: 'api_key', + auth_config: { key: 'test_key', location: 'query', name: 'api_key' }) + + expect(tool.build_auth_headers).to eq({}) + end + + it 'returns empty hash for basic auth' do + tool = create(:captain_custom_tool, :with_basic_auth, account: account) + + expect(tool.build_auth_headers).to eq({}) + end + end + + describe '#build_basic_auth_credentials' do + it 'returns nil for non-basic auth types' do + tool = create(:captain_custom_tool, account: account, auth_type: 'none') + + expect(tool.build_basic_auth_credentials).to be_nil + end + + it 'returns username and password array for basic auth' do + tool = create(:captain_custom_tool, :with_basic_auth, account: account) + + expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass]) + end + end + + describe '#format_response' do + it 'returns raw response when no response_template' do + tool = create(:captain_custom_tool, account: account, response_template: nil) + + expect(tool.format_response('raw response')).to eq('raw response') + end + + it 'renders response template with JSON response' do + tool = create(:captain_custom_tool, account: account, + response_template: 'Order status: {{ response.status }}') + raw_response = '{"status": "shipped", "tracking": "123ABC"}' + + result = tool.format_response(raw_response) + expect(result).to eq('Order status: shipped') + end + + it 'handles response template with multiple fields' do + tool = create(:captain_custom_tool, account: account, + response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}') + raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}' + + result = tool.format_response(raw_response) + expect(result).to eq('Order 12345 is delivered. Tracking: ABC123') + end + + it 'handles non-JSON response' do + tool = create(:captain_custom_tool, account: account, + response_template: 'Response: {{ response }}') + raw_response = 'plain text response' + + result = tool.format_response(raw_response) + expect(result).to eq('Response: plain text response') + end + end + + describe '#to_tool_metadata' do + it 'returns tool metadata hash with custom flag' do + tool = create(:captain_custom_tool, account: account, + slug: 'custom_test-tool', + title: 'Test Tool', + description: 'A test tool') + + metadata = tool.to_tool_metadata + expect(metadata).to eq({ + id: 'custom_test-tool', + title: 'Test Tool', + description: 'A test tool', + custom: true + }) + end + end + + describe '#tool' do + let(:assistant) { create(:captain_assistant, account: account) } + + it 'returns HttpTool instance' do + tool = create(:captain_custom_tool, account: account) + + tool_instance = tool.tool(assistant) + expect(tool_instance).to be_a(Captain::Tools::HttpTool) + end + + it 'sets description on the tool class' do + tool = create(:captain_custom_tool, account: account, description: 'Fetches order data') + + tool_instance = tool.tool(assistant) + expect(tool_instance.description).to eq('Fetches order data') + end + + it 'sets parameters on the tool class' do + tool = create(:captain_custom_tool, :with_params, account: account) + + tool_instance = tool.tool(assistant) + params = tool_instance.parameters + + expect(params.keys).to contain_exactly(:order_id, :include_details) + expect(params[:order_id].name).to eq(:order_id) + expect(params[:order_id].type).to eq('string') + expect(params[:order_id].description).to eq('The order ID') + expect(params[:order_id].required).to be true + + expect(params[:include_details].name).to eq(:include_details) + expect(params[:include_details].required).to be false + end + + it 'works with empty param_schema' do + tool = create(:captain_custom_tool, account: account, param_schema: []) + + tool_instance = tool.tool(assistant) + expect(tool_instance.parameters).to be_empty + end + end + end +end diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index 7a39559c3..45009a3b0 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do before do # Mock available tools - allow(described_class).to receive(:available_tool_ids).and_return(%w[ - add_contact_note add_private_note update_priority - ]) + allow(described_class).to receive(:built_in_tool_ids).and_return(%w[ + add_contact_note add_private_note update_priority + ]) end describe 'validate_instruction_tools' do @@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do expect(scenario).not_to be_valid expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) end + + it 'is valid with custom tool references' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).to be_valid + end + + it 'is invalid with custom tool from different account' do + other_account = create(:account) + create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') + end + + it 'is invalid with disabled custom tool' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details') + + expect(scenario).not_to be_valid + expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order') + end + + it 'is valid with mixed static and custom tool references' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = build(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + expect(scenario).to be_valid + end end describe 'resolve_tool_references' do @@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do end end + describe 'custom tool integration' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + before do + allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note]) + allow(described_class).to receive(:built_in_agent_tools).and_return([ + { id: 'add_contact_note', title: 'Add Contact Note', + description: 'Add a note' } + ]) + end + + describe '#resolved_tools' do + it 'includes custom tool metadata' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', + title: 'Fetch Order', description: 'Gets order details') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + resolved = scenario.send(:resolved_tools) + expect(resolved.length).to eq(1) + expect(resolved.first[:id]).to eq('custom_fetch-order') + expect(resolved.first[:title]).to eq('Fetch Order') + expect(resolved.first[:description]).to eq('Gets order details') + end + + it 'includes both static and custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + resolved = scenario.send(:resolved_tools) + expect(resolved.length).to eq(2) + expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order') + end + + it 'excludes disabled custom tools' do + custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + custom_tool.update!(enabled: false) + + resolved = scenario.send(:resolved_tools) + expect(resolved).to be_empty + end + end + + describe '#resolve_tool_instance' do + it 'returns HttpTool instance for custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, assistant: assistant, account: account) + + tool_metadata = { id: 'custom_fetch-order', custom: true } + tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) + expect(tool_instance).to be_a(Captain::Tools::HttpTool) + end + + it 'returns nil for disabled custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false) + scenario = create(:captain_scenario, assistant: assistant, account: account) + + tool_metadata = { id: 'custom_fetch-order', custom: true } + tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) + expect(tool_instance).to be_nil + end + + it 'returns static tool instance for non-custom tools' do + scenario = create(:captain_scenario, assistant: assistant, account: account) + allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( + Class.new do + def initialize(_assistant); end + end + ) + + tool_metadata = { id: 'add_contact_note' } + tool_instance = scenario.send(:resolve_tool_instance, tool_metadata) + expect(tool_instance).not_to be_nil + expect(tool_instance).not_to be_a(Captain::Tools::HttpTool) + end + end + + describe '#agent_tools' do + it 'returns array of tool instances including custom tools' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + tools = scenario.send(:agent_tools) + expect(tools.length).to eq(1) + expect(tools.first).to be_a(Captain::Tools::HttpTool) + end + + it 'excludes disabled custom tools from execution' do + custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true) + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Fetch Order](tool://custom_fetch-order)') + + custom_tool.update!(enabled: false) + + tools = scenario.send(:agent_tools) + expect(tools).to be_empty + end + + it 'returns mixed static and custom tool instances' do + create(:captain_custom_tool, account: account, slug: 'custom_fetch-order') + scenario = create(:captain_scenario, + assistant: assistant, + account: account, + instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)') + + allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return( + Class.new do + def initialize(_assistant); end + end + ) + + tools = scenario.send(:agent_tools) + expect(tools.length).to eq(2) + expect(tools.last).to be_a(Captain::Tools::HttpTool) + end + end + end + describe 'factory' do it 'creates a valid scenario with associations' do account = create(:account) diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb index afe482385..7e36e9006 100644 --- a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do end end - describe '.available_agent_tools' do - before do - # Mock the YAML file loading - allow(YAML).to receive(:load_file).and_return([ - { - 'id' => 'add_contact_note', - 'title' => 'Add Contact Note', - 'description' => 'Add a note to a contact', - 'icon' => 'note-add' - }, - { - 'id' => 'invalid_tool', - 'title' => 'Invalid Tool', - 'description' => 'This tool does not exist', - 'icon' => 'invalid' - } - ]) - - # Mock class resolution - only add_contact_note exists - allow(test_class).to receive(:resolve_tool_class) do |tool_id| - case tool_id - when 'add_contact_note' - Captain::Tools::AddContactNoteTool - end - end - end - - it 'returns only resolvable tools' do - tools = test_class.available_agent_tools - - expect(tools.length).to eq(1) - expect(tools.first).to eq({ - id: 'add_contact_note', - title: 'Add Contact Note', - description: 'Add a note to a contact', - icon: 'note-add' - }) - end - - it 'logs warnings for unresolvable tools' do - expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool') - - test_class.available_agent_tools - end - - it 'memoizes the result' do - expect(YAML).to receive(:load_file).once.and_return([]) - - 2.times { test_class.available_agent_tools } - end - end - describe '.resolve_tool_class' do it 'resolves valid tool classes' do # Mock the constantize to return a class @@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do end end - describe '.available_tool_ids' do - before do - allow(test_class).to receive(:available_agent_tools).and_return([ - { id: 'add_contact_note', title: 'Add Contact Note', description: '...', - icon: 'note' }, - { id: 'update_priority', title: 'Update Priority', description: '...', - icon: 'priority' } - ]) - end - - it 'returns array of tool IDs' do - ids = test_class.available_tool_ids - expect(ids).to eq(%w[add_contact_note update_priority]) - end - - it 'memoizes the result' do - expect(test_class).to receive(:available_agent_tools).once.and_return([]) - - 2.times { test_class.available_tool_ids } - end - end - describe '#extract_tool_ids_from_text' do it 'extracts tool IDs from text' do text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)' diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb new file mode 100644 index 000000000..2bfcbf360 --- /dev/null +++ b/spec/factories/captain/custom_tool.rb @@ -0,0 +1,51 @@ +FactoryBot.define do + factory :captain_custom_tool, class: 'Captain::CustomTool' do + sequence(:title) { |n| "Custom Tool #{n}" } + description { 'A custom HTTP tool for external API integration' } + endpoint_url { 'https://api.example.com/endpoint' } + http_method { 'GET' } + auth_type { 'none' } + auth_config { {} } + param_schema { [] } + enabled { true } + association :account + + trait :with_post do + http_method { 'POST' } + request_template { '{ "key": "{{ value }}" }' } + end + + trait :with_bearer_auth do + auth_type { 'bearer' } + auth_config { { token: 'test_bearer_token_123' } } + end + + trait :with_basic_auth do + auth_type { 'basic' } + auth_config { { username: 'test_user', password: 'test_pass' } } + end + + trait :with_api_key do + auth_type { 'api_key' } + auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } } + end + + trait :with_templates do + request_template { '{ "order_id": "{{ order_id }}", "source": "chatwoot" }' } + response_template { 'Order status: {{ response.status }}' } + end + + trait :with_params do + param_schema do + [ + { 'name' => 'order_id', 'type' => 'string', 'description' => 'The order ID', 'required' => true }, + { 'name' => 'include_details', 'type' => 'boolean', 'description' => 'Include order details', 'required' => false } + ] + end + end + + trait :disabled do + enabled { false } + end + end +end From 9fb0dfa4a7b1f5bc962290589113b7b8f982829c Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 6 Oct 2025 21:35:54 +0530 Subject: [PATCH 3/8] feat: Add UI for custom tools (#12585) ### Tools list CleanShot 2025-10-03 at 20 42 41@2x ### Tools form CleanShot 2025-10-03 at 20 43
05@2x ## Response CleanShot 2025-10-03 at 20 45 56@2x --------- Co-authored-by: Pranav Co-authored-by: Pranav --- .../dashboard/api/captain/customTools.js | 36 +++ .../captain/pageComponents/DeleteDialog.vue | 8 +- .../pageComponents/customTool/AuthConfig.vue | 73 +++++ .../customTool/CreateCustomToolDialog.vue | 87 ++++++ .../customTool/CustomToolCard.vue | 125 ++++++++ .../customTool/CustomToolForm.vue | 271 +++++++++++++++++ .../pageComponents/customTool/ParamRow.vue | 113 +++++++ .../emptyStates/CustomToolsPageEmptyState.vue | 29 ++ .../components-next/sidebar/Sidebar.vue | 5 + .../i18n/locale/en/integrations.json | 109 +++++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/captain/captain.routes.js | 14 + .../routes/dashboard/captain/tools/Index.vue | 138 +++++++++ .../dashboard/store/captain/customTools.js | 35 +++ .../dashboard/store/captain/tools.js | 2 +- app/javascript/dashboard/store/index.js | 2 + config/locales/en.yml | 2 + config/routes.rb | 1 + .../captain/custom_tools_controller.rb | 49 +++ enterprise/app/models/captain/custom_tool.rb | 19 +- enterprise/app/models/concerns/toolable.rb | 13 + .../policies/captain/custom_tool_policy.rb | 21 ++ .../captain/custom_tools/create.json.jbuilder | 1 + .../captain/custom_tools/index.json.jbuilder | 10 + .../captain/custom_tools/show.json.jbuilder | 1 + .../captain/custom_tools/update.json.jbuilder | 1 + .../models/captain/_custom_tool.json.jbuilder | 15 + .../captain/custom_tools_controller_spec.rb | 281 ++++++++++++++++++ .../models/captain/custom_tool_spec.rb | 36 +-- 29 files changed, 1474 insertions(+), 24 deletions(-) create mode 100644 app/javascript/dashboard/api/captain/customTools.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue create mode 100644 app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue create mode 100644 app/javascript/dashboard/store/captain/customTools.js create mode 100644 enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb create mode 100644 enterprise/app/policies/captain/custom_tool_policy.rb create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder create mode 100644 enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder create mode 100644 enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder create mode 100644 spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js new file mode 100644 index 000000000..d0818d941 --- /dev/null +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -0,0 +1,36 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class CaptainCustomTools extends ApiClient { + constructor() { + super('captain/custom_tools', { accountScoped: true }); + } + + get({ page = 1, searchKey } = {}) { + return axios.get(this.url, { + params: { page, searchKey }, + }); + } + + show(id) { + return axios.get(`${this.url}/${id}`); + } + + create(data = {}) { + return axios.post(this.url, { + custom_tool: data, + }); + } + + update(id, data = {}) { + return axios.put(`${this.url}/${id}`, { + custom_tool: data, + }); + } + + delete(id) { + return axios.delete(`${this.url}/${id}`); + } +} + +export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue index 31e18394f..8d67344e1 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/DeleteDialog.vue @@ -10,6 +10,10 @@ const props = defineProps({ type: String, required: true, }, + translationKey: { + type: String, + required: true, + }, entity: { type: Object, required: true, @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); const { t } = useI18n(); const store = useStore(); const deleteDialogRef = ref(null); -const i18nKey = computed(() => props.type.toUpperCase()); +const i18nKey = computed(() => { + return props.translationKey || props.type.toUpperCase(); +}); const deleteEntity = async payload => { if (!payload) return; diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue new file mode 100644 index 000000000..208a94dba --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/AuthConfig.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue new file mode 100644 index 000000000..0745c6546 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue @@ -0,0 +1,87 @@ + + +