From 8bbb8ba5a4495b848dae7ce3afcef412f5fb4861 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 6 Oct 2025 20:23:15 +0530 Subject: [PATCH 1/7] 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 2/7] 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 @@ + + +