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 @@ + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue new file mode 100644 index 000000000..d1d1dd011 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue @@ -0,0 +1,125 @@ + + + + + + + {{ title }} + + + + + + + + + + + + {{ description }} + + + + {{ authTypeLabel }} + + + + {{ timestamp }} + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue new file mode 100644 index 000000000..14ebc6a57 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolForm.vue @@ -0,0 +1,271 @@ + + + + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }} + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }} + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }} + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue new file mode 100644 index 000000000..33cd64468 --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/ParamRow.vue @@ -0,0 +1,113 @@ + + + + + + + + + + + + + + + {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }} + + + + + + + {{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }} + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue new file mode 100644 index 000000000..420f953da --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue @@ -0,0 +1,29 @@ + + + + + + + + + + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 28733565f..25aaebe0e 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -238,6 +238,11 @@ const menuItems = computed(() => { label: t('SIDEBAR.CAPTAIN_RESPONSES'), to: accountScopedRoute('captain_responses_index'), }, + { + name: 'Tools', + label: t('SIDEBAR.CAPTAIN_TOOLS'), + to: accountScopedRoute('captain_tools_index'), + }, ], }, { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 0fb2322d2..87227e74b 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -49,6 +49,5 @@ export const PREMIUM_FEATURES = [ FEATURE_FLAGS.CUSTOM_ROLES, FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.HELP_CENTER, - FEATURE_FLAGS.CAPTAIN_V2, FEATURE_FLAGS.SAML, ]; diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 8a812dff3..c65d2d040 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -750,6 +750,115 @@ } } }, + "CUSTOM_TOOLS": { + "HEADER": "Tools", + "ADD_NEW": "Create a new tool", + "EMPTY_STATE": { + "TITLE": "No custom tools available", + "SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.", + "FEATURE_SPOTLIGHT": { + "TITLE": "Custom Tools", + "NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities." + } + }, + "FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs", + "OPTIONS": { + "EDIT_TOOL": "Edit tool", + "DELETE_TOOL": "Delete tool" + }, + "CREATE": { + "TITLE": "Create Custom Tool", + "SUCCESS_MESSAGE": "Custom tool created successfully", + "ERROR_MESSAGE": "Failed to create custom tool" + }, + "EDIT": { + "TITLE": "Edit Custom Tool", + "SUCCESS_MESSAGE": "Custom tool updated successfully", + "ERROR_MESSAGE": "Failed to update custom tool" + }, + "DELETE": { + "TITLE": "Delete Custom Tool", + "DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.", + "CONFIRM": "Yes, delete", + "SUCCESS_MESSAGE": "Custom tool deleted successfully", + "ERROR_MESSAGE": "Failed to delete custom tool" + }, + "FORM": { + "TITLE": { + "LABEL": "Tool Name", + "PLACEHOLDER": "Order Lookup", + "ERROR": "Tool name is required" + }, + "DESCRIPTION": { + "LABEL": "Description", + "PLACEHOLDER": "Looks up order details by order ID" + }, + "HTTP_METHOD": { + "LABEL": "Method" + }, + "ENDPOINT_URL": { + "LABEL": "Endpoint URL", + "PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}", + "ERROR": "Valid URL is required" + }, + "AUTH_TYPE": { + "LABEL": "Authentication Type" + }, + "AUTH_TYPES": { + "NONE": "None", + "BEARER": "Bearer Token", + "BASIC": "Basic Auth", + "API_KEY": "API Key" + }, + "AUTH_CONFIG": { + "BEARER_TOKEN": "Bearer Token", + "BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token", + "USERNAME": "Username", + "USERNAME_PLACEHOLDER": "Enter username", + "PASSWORD": "Password", + "PASSWORD_PLACEHOLDER": "Enter password", + "API_KEY": "Header Name", + "API_KEY_PLACEHOLDER": "X-API-Key", + "API_VALUE": "Header Value", + "API_VALUE_PLACEHOLDER": "Enter API key value" + }, + "PARAMETERS": { + "LABEL": "Parameters", + "HELP_TEXT": "Define the parameters that will be extracted from user queries" + }, + "ADD_PARAMETER": "Add Parameter", + "PARAM_NAME": { + "PLACEHOLDER": "Parameter name (e.g., order_id)" + }, + "PARAM_TYPE": { + "PLACEHOLDER": "Type" + }, + "PARAM_TYPES": { + "STRING": "String", + "NUMBER": "Number", + "BOOLEAN": "Boolean", + "ARRAY": "Array", + "OBJECT": "Object" + }, + "PARAM_DESCRIPTION": { + "PLACEHOLDER": "Description of the parameter" + }, + "PARAM_REQUIRED": { + "LABEL": "Required" + }, + "REQUEST_TEMPLATE": { + "LABEL": "Request Body Template (Optional)", + "PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}" + }, + "RESPONSE_TEMPLATE": { + "LABEL": "Response Template (Optional)", + "PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}" + }, + "ERRORS": { + "PARAM_NAME_REQUIRED": "Parameter name is required" + } + } + }, "RESPONSES": { "HEADER": "FAQs", "ADD_NEW": "Create new FAQ", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 9ddc3b805..812b0cd8b 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -304,6 +304,7 @@ "CAPTAIN_ASSISTANTS": "Assistants", "CAPTAIN_DOCUMENTS": "Documents", "CAPTAIN_RESPONSES": "FAQs", + "CAPTAIN_TOOLS": "Tools", "HOME": "Home", "AGENTS": "Agents", "AGENT_BOTS": "Bots", diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js index 52fda537b..9d5609ab7 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js +++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js @@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; import DocumentsIndex from './documents/Index.vue'; import ResponsesIndex from './responses/Index.vue'; +import CustomToolsIndex from './tools/Index.vue'; export const routes = [ { @@ -124,4 +125,17 @@ export const routes = [ ], }, }, + { + path: frontendURL('accounts/:accountId/captain/tools'), + component: CustomToolsIndex, + name: 'captain_tools_index', + meta: { + permissions: ['administrator', 'agent'], + featureFlag: FEATURE_FLAGS.CAPTAIN_V2, + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, ]; diff --git a/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue new file mode 100644 index 000000000..880bdbbf3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/tools/Index.vue @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/store/captain/customTools.js b/app/javascript/dashboard/store/captain/customTools.js new file mode 100644 index 000000000..3d3af03c0 --- /dev/null +++ b/app/javascript/dashboard/store/captain/customTools.js @@ -0,0 +1,35 @@ +import CaptainCustomTools from 'dashboard/api/captain/customTools'; +import { createStore } from './storeFactory'; +import { throwErrorMessage } from 'dashboard/store/utils/api'; + +export default createStore({ + name: 'CaptainCustomTool', + API: CaptainCustomTools, + actions: mutations => ({ + update: async ({ commit }, { id, ...updateObj }) => { + commit(mutations.SET_UI_FLAG, { updatingItem: true }); + try { + const response = await CaptainCustomTools.update(id, updateObj); + commit(mutations.EDIT, response.data); + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return response.data; + } catch (error) { + commit(mutations.SET_UI_FLAG, { updatingItem: false }); + return throwErrorMessage(error); + } + }, + + delete: async ({ commit }, id) => { + commit(mutations.SET_UI_FLAG, { deletingItem: true }); + try { + await CaptainCustomTools.delete(id); + commit(mutations.DELETE, id); + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return id; + } catch (error) { + commit(mutations.SET_UI_FLAG, { deletingItem: false }); + return throwErrorMessage(error); + } + }, + }), +}); diff --git a/app/javascript/dashboard/store/captain/tools.js b/app/javascript/dashboard/store/captain/tools.js index 9a9bcc330..9638e45c3 100644 --- a/app/javascript/dashboard/store/captain/tools.js +++ b/app/javascript/dashboard/store/captain/tools.js @@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools'; import { throwErrorMessage } from 'dashboard/store/utils/api'; const toolsStore = createStore({ - name: 'captainTool', + name: 'Tools', API: CaptainToolsAPI, actions: mutations => ({ getTools: async ({ commit }) => { diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 16bcab3f9..d56958eb5 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads'; import copilotMessages from './captain/copilotMessages'; import captainScenarios from './captain/scenarios'; import captainTools from './captain/tools'; +import captainCustomTools from './captain/customTools'; const plugins = []; @@ -119,6 +120,7 @@ export default createStore({ copilotMessages, captainScenarios, captainTools, + captainCustomTools, }, plugins, }); diff --git a/app/models/custom_filter.rb b/app/models/custom_filter.rb index b3d58fd17..6d64c0447 100644 --- a/app/models/custom_filter.rb +++ b/app/models/custom_filter.rb @@ -17,7 +17,6 @@ # index_custom_filters_on_user_id (user_id) # class CustomFilter < ApplicationRecord - MAX_FILTER_PER_USER = 50 belongs_to :user belongs_to :account @@ -25,7 +24,7 @@ class CustomFilter < ApplicationRecord validate :validate_number_of_filters def validate_number_of_filters - return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER + return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER errors.add :account_id, I18n.t('errors.custom_filters.number_of_records') end diff --git a/config/initializers/ai_agents.rb b/config/initializers/ai_agents.rb index 37bdd589f..099d637ae 100644 --- a/config/initializers/ai_agents.rb +++ b/config/initializers/ai_agents.rb @@ -15,6 +15,7 @@ Rails.application.config.after_initialize do config.openai_api_base = api_base end config.default_model = model + config.max_turns = 30 config.debug = false end end diff --git a/config/locales/en.yml b/config/locales/en.yml index ca3a9e950..0c76f8beb 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -100,7 +100,7 @@ en: validations: name: should not start or end with symbols, and it should not have < > / \ @ characters. custom_filters: - number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50. + number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000. invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account. invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}]. invalid_query_operator: Query operator must be either "AND" or "OR". @@ -336,6 +336,8 @@ en: processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' + custom_tool: + slug_generation_failed: 'Unable to generate unique slug after 5 attempts' public_portal: search: search_placeholder: Search for article by title or body... diff --git a/config/routes.rb b/config/routes.rb index bf455949c..757d20620 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -67,6 +67,7 @@ Rails.application.routes.draw do resources :copilot_threads, only: [:index, :create] do resources :copilot_messages, only: [:index, :create] end + resources :custom_tools resources :documents, only: [:index, :show, :create, :destroy] end resource :saml_settings, only: [:show, :create, :update, :destroy] 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/controllers/api/v1/accounts/captain/custom_tools_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb new file mode 100644 index 000000000..3137ded09 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/custom_tools_controller.rb @@ -0,0 +1,49 @@ +class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController + before_action :current_account + before_action -> { check_authorization(Captain::CustomTool) } + before_action :set_custom_tool, only: [:show, :update, :destroy] + + def index + @custom_tools = account_custom_tools.enabled + end + + def show; end + + def create + @custom_tool = account_custom_tools.create!(custom_tool_params) + end + + def update + @custom_tool.update!(custom_tool_params) + end + + def destroy + @custom_tool.destroy + head :no_content + end + + private + + def set_custom_tool + @custom_tool = account_custom_tools.find(params[:id]) + end + + def account_custom_tools + @account_custom_tools ||= Current.account.captain_custom_tools + end + + def custom_tool_params + params.require(:custom_tool).permit( + :title, + :description, + :endpoint_url, + :http_method, + :request_template, + :response_template, + :auth_type, + :enabled, + auth_config: {}, + param_schema: [:name, :type, :description, :required] + ) + end +end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 7ede1201d..15f2ace56 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -49,10 +49,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob .where(message_type: [:incoming, :outgoing]) .where(private: false) .map do |message| - { + message_hash = { content: prepare_multimodal_message_content(message), role: determine_role(message) } + + # Include agent_name if present in additional_attributes + message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present? + + message_hash end end @@ -79,25 +84,31 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def create_handoff_message - create_outgoing_message(@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')) + create_outgoing_message( + @assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff') + ) end def create_messages validate_message_content!(@response['response']) - create_outgoing_message(@response['response']) + create_outgoing_message(@response['response'], agent_name: @response['agent_name']) end def validate_message_content!(content) raise ArgumentError, 'Message content cannot be blank' if content.blank? end - def create_outgoing_message(message_content) + def create_outgoing_message(message_content, agent_name: nil) + additional_attrs = {} + additional_attrs[:agent_name] = agent_name if agent_name.present? + @conversation.messages.create!( message_type: :outgoing, account_id: account.id, inbox_id: inbox.id, sender: @assistant, - content: message_content + content: message_content, + additional_attributes: additional_attrs ) end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index 0423abf67..771360659 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, @@ -92,6 +105,7 @@ class Captain::Assistant < ApplicationRecord product_name: config['product_name'] || 'this product', scenarios: scenarios.enabled.map do |scenario| { + title: scenario.title, key: scenario.title.parameterize.underscore, description: scenario.description } diff --git a/enterprise/app/models/captain/custom_tool.rb b/enterprise/app/models/captain/custom_tool.rb new file mode 100644 index 000000000..bf3f351dd --- /dev/null +++ b/enterprise/app/models/captain/custom_tool.rb @@ -0,0 +1,100 @@ +# == 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' + + NAME_PREFIX = 'custom'.freeze + NAME_SEPARATOR = '_'.freeze + 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? + return if title.blank? + + paramterized_title = title.parameterize(separator: NAME_SEPARATOR) + + base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{paramterized_title}" + self.slug = find_unique_slug(base_slug) + end + + def find_unique_slug(base_slug) + return base_slug unless slug_exists?(base_slug) + + 5.times do + slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}" + return slug_candidate unless slug_exists?(slug_candidate) + end + + raise ActiveRecord::RecordNotUnique, I18n.t('captain.custom_tool.slug_generation_failed') + 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..d876a7127 100644 --- a/enterprise/app/models/captain/scenario.rb +++ b/enterprise/app/models/captain/scenario.rb @@ -38,7 +38,7 @@ class Captain::Scenario < ApplicationRecord scope :enabled, -> { where(enabled: true) } - delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant + delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant before_save :resolve_tool_references @@ -46,7 +46,10 @@ class Captain::Scenario < ApplicationRecord { title: title, instructions: resolved_instructions, - tools: resolved_tools + tools: resolved_tools, + assistant_name: assistant.name.downcase.gsub(/\s+/, '_'), + response_guidelines: response_guidelines || [], + guardrails: guardrails || [] } end @@ -57,24 +60,34 @@ 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 - instruction.gsub(TOOL_REFERENCE_REGEX) do |match| - "#{match} tool " - end + instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool') end 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 +108,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..ad047e8f8 --- /dev/null +++ b/enterprise/app/models/concerns/toolable.rb @@ -0,0 +1,91 @@ +module Concerns::Toolable + extend ActiveSupport::Concern + + def tool(assistant) + custom_tool_record = self + # Convert slug to valid Ruby constant name (replace hyphens with underscores, then camelize) + class_name = custom_tool_record.slug.underscore.camelize + + # Always create a fresh class to reflect current metadata + 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 + + # Register the dynamically created class as a constant in the Captain::Tools namespace. + # This is required because RubyLLM's Tool base class derives the tool name from the class name + # (via Class#name). Anonymous classes created with Class.new have no name and return empty strings, + # which causes "Invalid 'tools[].function.name': empty string" errors from the LLM API. + # By setting it as a constant, the class gets a proper name (e.g., "Captain::Tools::CatFactLookup") + # which RubyLLM extracts and normalizes to "cat-fact-lookup" for the LLM API. + # We refresh the constant on each call to ensure tool metadata changes are reflected. + Captain::Tools.send(:remove_const, class_name) if Captain::Tools.const_defined?(class_name, false) + Captain::Tools.const_set(class_name, tool_class) + + 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, 'r' => 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/app/policies/captain/custom_tool_policy.rb b/enterprise/app/policies/captain/custom_tool_policy.rb new file mode 100644 index 000000000..b88a23860 --- /dev/null +++ b/enterprise/app/policies/captain/custom_tool_policy.rb @@ -0,0 +1,21 @@ +class Captain::CustomToolPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 7a35e6d07..11a7dcad1 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -74,7 +74,12 @@ class Captain::Assistant::AgentRunnerService # Response formatting methods def process_agent_result(result) Rails.logger.info "[Captain V2] Agent result: #{result.inspect}" - format_response(result.output) + response = format_response(result.output) + + # Extract agent name from context + response['agent_name'] = result.context&.dig(:current_agent) + + response end def format_response(output) diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/create.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder new file mode 100644 index 000000000..c57a92261 --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/index.json.jbuilder @@ -0,0 +1,10 @@ +json.payload do + json.array! @custom_tools do |custom_tool| + json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool + end +end + +json.meta do + json.total_count @custom_tools.count + json.page 1 +end diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/show.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder new file mode 100644 index 000000000..baf3cb3ac --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/custom_tools/update.json.jbuilder @@ -0,0 +1 @@ +json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool diff --git a/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder new file mode 100644 index 000000000..778b30061 --- /dev/null +++ b/enterprise/app/views/api/v1/models/captain/_custom_tool.json.jbuilder @@ -0,0 +1,15 @@ +json.id custom_tool.id +json.slug custom_tool.slug +json.title custom_tool.title +json.description custom_tool.description +json.endpoint_url custom_tool.endpoint_url +json.http_method custom_tool.http_method +json.request_template custom_tool.request_template +json.response_template custom_tool.response_template +json.auth_type custom_tool.auth_type +json.auth_config custom_tool.auth_config +json.param_schema custom_tool.param_schema +json.enabled custom_tool.enabled +json.account_id custom_tool.account_id +json.created_at custom_tool.created_at.to_i +json.updated_at custom_tool.updated_at.to_i diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 69c967d73..0dc7d8577 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -2,12 +2,13 @@ You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses. # Your Identity -You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need. +You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need. {{ description }} -Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this. +Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this. +{% if conversation || contact -%} # Current Context Here's the metadata we have about the current conversation and the contact associated with it: @@ -19,12 +20,16 @@ Here's the metadata we have about the current conversation and the contact assoc {% if contact -%} {% render 'contact' %} {% endif -%} +{% endif -%} {% if response_guidelines.size > 0 -%} # Response Guidelines Your responses should follow these guidelines: {% for guideline in response_guidelines -%} - {{ guideline }} +- Be conversational but professional +- Provide actionable information +- Include relevant details from tool responses {% endfor %} {% endif -%} @@ -45,30 +50,26 @@ First, understand what the user is asking: - **Complexity**: Can you handle it or does it need specialized expertise? ## 2. Check for Specialized Scenarios First -Before using any tools, check if the request matches any of these scenarios. If unclear, ask clarifying questions to determine if a scenario applies: + +Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you. {% for scenario in scenarios -%} -### handoff_to_{{ scenario.key }} -{{ scenario.description }} -{% endfor -%} +- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent. +{% endfor %} +If unclear, ask clarifying questions to determine if a scenario applies: ## 3. Handle the Request -If no specialized scenario clearly matches, handle it yourself: +If no specialized scenario clearly matches, handle it yourself in the following way ### For Questions and Information Requests -1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information -2. **If not found in FAQs**: Provide your best answer based on available context -3. **If unable to answer**: Use `handoff` tool to transfer to a human expert +1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information +2. **If not found in FAQs**: Try to ask clarifying questions to gather more information +3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert ### For Complex or Unclear Requests 1. **Ask clarifying questions**: Gather more information if needed 2. **Break down complex tasks**: Handle step by step or hand off if too complex -3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities - -## Response Best Practices -- Be conversational but professional -- Provide actionable information -- Include relevant details from tool responses +3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities # Human Handoff Protocol Transfer to a human agent when: @@ -77,4 +78,4 @@ Transfer to a human agent when: - The issue requires specialized knowledge or permissions you don't have - Multiple attempts to help have been unsuccessful -When using the `handoff` tool, provide a clear reason that helps the human agent understand the context. +When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context. diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 339820b83..1148a7c3a 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -1,20 +1,44 @@ # System context -You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. -The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally. +You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally. # Your Role -You are a specialized agent called {{ title }}, your task is to handle the following scenario: +You are a specialized agent called "{{ title }}", your task is to handle the following scenario: {{ instructions }} +If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool + +{% if conversation || contact %} +# Current Context + +Here's the metadata we have about the current conversation and the contact associated with it: + {% if conversation -%} {% render 'conversation' %} +{% endif -%} {% if contact -%} {% render 'contact' %} {% endif -%} {% endif -%} + +{% if response_guidelines.size > 0 -%} +# Response Guidelines +Your responses should follow these guidelines: +{% for guideline in response_guidelines -%} +- {{ guideline }} +{% endfor %} +{% endif -%} + +{% if guardrails.size > 0 -%} +# Guardrails +Always respect these boundaries: +{% for guardrail in guardrails -%} +- {{ guardrail }} +{% endfor %} +{% endif -%} + {% if tools.size > 0 -%} # Available Tools You have access to these tools: 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/limits.rb b/lib/limits.rb index 7a2371207..5da178bf4 100644 --- a/lib/limits.rb +++ b/lib/limits.rb @@ -6,6 +6,7 @@ module Limits GREETING_MESSAGE_MAX_LENGTH = 10_000 CATEGORIES_PER_PAGE = 1000 AUTO_ASSIGNMENT_BULK_LIMIT = 100 + MAX_CUSTOM_FILTERS_PER_USER = 1000 def self.conversation_message_per_minute_limit ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i 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/controllers/api/v1/accounts/custom_filters_controller_spec.rb b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb index 760100cf3..9c9469090 100644 --- a/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/custom_filters_controller_spec.rb @@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do expect(json_response['name']).to eq 'vip-customers' end - it 'gives the error for 51st record' do + it 'gives the error for 1001st record' do CustomFilter.delete_all - CustomFilter::MAX_FILTER_PER_USER.times do + Limits::MAX_CUSTOM_FILTERS_PER_USER.times do create(:custom_filter, user: user, account: account) end @@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do expect(response).to have_http_status(:unprocessable_entity) json_response = response.parsed_body expect(json_response['message']).to include( - 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.' + 'Account Limit reached. The maximum number of allowed custom filters for a user per account is 1000.' ) end end diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb new file mode 100644 index 000000000..7a1526995 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/custom_tools_controller_spec.rb @@ -0,0 +1,281 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do + let(:account) { create(:account) } + let(:admin) { create(:user, account: account, role: :administrator) } + let(:agent) { create(:user, account: account, role: :agent) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools' do + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status' do + create_list(:captain_custom_tool, 3, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(3) + end + end + + context 'when it is an admin' do + it 'returns success status and custom tools' do + create_list(:captain_custom_tool, 5, account: account) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(5) + end + + it 'returns only enabled custom tools' do + create(:captain_custom_tool, account: account, enabled: true) + create(:captain_custom_tool, account: account, enabled: false) + get "/api/v1/accounts/#{account.id}/captain/custom_tools", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:payload].length).to eq(1) + expect(json_response[:payload].first[:enabled]).to be(true) + end + end + end + + describe 'GET /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns success status and custom tool' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:id]).to eq(custom_tool.id) + expect(json_response[:title]).to eq(custom_tool.title) + end + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + get "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", + headers: agent.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + + describe 'POST /api/v1/accounts/{account.id}/captain/custom_tools' do + let(:valid_attributes) do + { + custom_tool: { + title: 'Fetch Order Status', + description: 'Fetches order status from external API', + endpoint_url: 'https://api.example.com/orders/{{ order_id }}', + http_method: 'GET', + enabled: true, + param_schema: [ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ] + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'creates a new custom tool and returns success status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: valid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Fetch Order Status') + expect(json_response[:description]).to eq('Fetches order status from external API') + expect(json_response[:enabled]).to be(true) + expect(json_response[:slug]).to eq('custom_fetch_order_status') + expect(json_response[:param_schema]).to eq([ + { name: 'order_id', type: 'string', description: 'The order ID', required: true } + ]) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '', + endpoint_url: '' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'with invalid endpoint URL' do + let(:invalid_url_attributes) do + { + custom_tool: { + title: 'Test Tool', + endpoint_url: 'http://localhost/api', + http_method: 'GET' + } + } + end + + it 'returns unprocessable entity status' do + post "/api/v1/accounts/#{account.id}/captain/custom_tools", + params: invalid_url_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'PATCH /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let(:custom_tool) { create(:captain_custom_tool, account: account) } + let(:update_attributes) do + { + custom_tool: { + title: 'Updated Tool Title', + enabled: false + } + } + end + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes, + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'updates the custom tool and returns success status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: update_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(json_response[:title]).to eq('Updated Tool Title') + expect(json_response[:enabled]).to be(false) + end + + context 'with invalid parameters' do + let(:invalid_attributes) do + { + custom_tool: { + title: '' + } + } + end + + it 'returns unprocessable entity status' do + patch "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + params: invalid_attributes, + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unprocessable_entity) + end + end + end + end + + describe 'DELETE /api/v1/accounts/{account.id}/captain/custom_tools/{id}' do + let!(:custom_tool) { create(:captain_custom_tool, account: account) } + + context 'when it is an un-authenticated user' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}" + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an agent' do + it 'returns unauthorized status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: agent.create_new_auth_token + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when it is an admin' do + it 'deletes the custom tool and returns no content status' do + expect do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/#{custom_tool.id}", + headers: admin.create_new_auth_token + end.to change(Captain::CustomTool, :count).by(-1) + + expect(response).to have_http_status(:no_content) + end + + context 'when custom tool does not exist' do + it 'returns not found status' do + delete "/api/v1/accounts/#{account.id}/captain/custom_tools/999999", + headers: admin.create_new_auth_token + + expect(response).to have_http_status(:not_found) + end + end + end + end +end 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..5f6c7b19a --- /dev/null +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -0,0 +1,388 @@ +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 random suffix' 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 match(/^custom_test_tool_[a-z0-9]{6}$/) + 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_abc123') + tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool') + + expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/) + expect(tool3.slug).not_to eq('custom_test_tool') + expect(tool3.slug).not_to eq('custom_test_tool_abc123') + end + + it 'does not generate slug when title is blank' do + tool = build(:captain_custom_tool, account: account, title: nil) + + expect(tool).not_to be_valid + expect(tool.errors[:title]).to include("can't be blank") + 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/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index f31177fc2..4ee269c48 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do let(:mock_runner) { instance_double(Agents::Runner) } let(:mock_agent) { instance_double(Agents::Agent) } let(:mock_scenario_agent) { instance_double(Agents::Agent) } - let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) } + let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) } let(:message_history) do [ @@ -99,7 +99,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do it 'processes and formats agent result' do result = service.generate_response(message_history: message_history) - expect(result).to eq({ 'response' => 'Test response' }) + expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil }) end context 'when no scenarios are enabled' do @@ -118,14 +118,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do end context 'when agent result is a string' do - let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') } + let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) } it 'formats string response correctly' do result = service.generate_response(message_history: message_history) expect(result).to eq({ 'response' => 'Simple string response', - 'reasoning' => 'Processed by agent' + 'reasoning' => 'Processed by agent', + 'agent_name' => nil }) end end 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
+ {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} +