Merge branch 'develop' into fix/agent-bot-assignment-webhook
This commit is contained in:
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+80
-6
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+12
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -63,6 +63,16 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasCustomTools = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
||||
) ||
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -364,14 +374,18 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -37,6 +37,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_INSTAGRAM: 'channel_instagram',
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
|
||||
@@ -807,6 +807,7 @@
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
|
||||
"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.",
|
||||
@@ -837,11 +838,18 @@
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"TEST": {
|
||||
"BUTTON": "Test connection",
|
||||
"SUCCESS": "Endpoint returned HTTP {status}",
|
||||
"ERROR": "Connection failed",
|
||||
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
"ERROR": "Tool name is required",
|
||||
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
|
||||
@@ -46,7 +46,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta: metaV2,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
|
||||
@@ -2,21 +2,29 @@
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const SOFT_LIMIT = 10;
|
||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||
|
||||
const showSoftLimitWarning = computed(
|
||||
() => !isV2.value && customToolsMeta.value.totalCount > SOFT_LIMIT
|
||||
);
|
||||
|
||||
const createDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const selectedTool = ref(null);
|
||||
@@ -86,21 +94,23 @@ onMounted(() => {
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!customTools.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||
:show-know-more="false"
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="showSoftLimitWarning"
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-lg bg-n-amber-2 text-n-amber-11"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert size-4 shrink-0" />
|
||||
{{ $t('CAPTAIN.CUSTOM_TOOLS.SOFT_LIMIT_WARNING') }}
|
||||
</div>
|
||||
<CustomToolCard
|
||||
v-for="tool in customTools"
|
||||
:id="tool.id"
|
||||
|
||||
@@ -37,6 +37,7 @@ class Attachment < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :message
|
||||
has_one_attached :file
|
||||
before_save :set_extension
|
||||
validate :acceptable_file
|
||||
validates :external_url, length: { maximum: Limits::URL_LENGTH_LIMIT }
|
||||
enum file_type: { :image => 0, :audio => 1, :video => 2, :file => 3, :location => 4, :fallback => 5, :share => 6, :story_mention => 7,
|
||||
@@ -111,6 +112,7 @@ class Attachment < ApplicationRecord
|
||||
def file_metadata
|
||||
metadata = {
|
||||
extension: extension,
|
||||
content_type: file.content_type,
|
||||
data_url: file_url,
|
||||
thumb_url: thumb_url,
|
||||
file_size: file.byte_size,
|
||||
@@ -154,6 +156,13 @@ class Attachment < ApplicationRecord
|
||||
}
|
||||
end
|
||||
|
||||
def set_extension
|
||||
return unless file.attached?
|
||||
return if extension.present?
|
||||
|
||||
self.extension = File.extname(file.filename.to_s).delete_prefix('.').presence
|
||||
end
|
||||
|
||||
def should_validate_file?
|
||||
return unless file.attached?
|
||||
# we are only limiting attachment types in case of website widget
|
||||
|
||||
@@ -387,6 +387,7 @@ en:
|
||||
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
|
||||
custom_tool:
|
||||
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
|
||||
limit_exceeded: 'You can create a maximum of %{limit} custom tools per account'
|
||||
public_portal:
|
||||
search:
|
||||
search_placeholder: Search for article by title or body...
|
||||
|
||||
+3
-1
@@ -72,7 +72,9 @@ Rails.application.routes.draw do
|
||||
resources :copilot_threads, only: [:index, :create] do
|
||||
resources :copilot_messages, only: [:index, :create]
|
||||
end
|
||||
resources :custom_tools
|
||||
resources :custom_tools do
|
||||
post :test, on: :collection
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resource :tasks, only: [], controller: 'tasks' do
|
||||
post :rewrite
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action :ensure_custom_tools_enabled
|
||||
before_action -> { check_authorization(Captain::CustomTool) }
|
||||
before_action :set_custom_tool, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@custom_tools = account_custom_tools.enabled
|
||||
@custom_tools = account_custom_tools
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@custom_tool = account_custom_tools.create!(custom_tool_params)
|
||||
rescue Captain::CustomTool::LimitExceededError => e
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
|
||||
def update
|
||||
@@ -22,8 +25,22 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def test
|
||||
tool = account_custom_tools.new(custom_tool_params)
|
||||
result = execute_test_request(tool)
|
||||
render json: { status: result.code.to_i, body: result.body.to_s.truncate(500) }
|
||||
rescue StandardError => e
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_custom_tools_enabled
|
||||
return if Current.account.feature_enabled?('custom_tools') || Current.account.feature_enabled?('captain_integration_v2')
|
||||
|
||||
render json: { error: 'Custom tools are not enabled for this account' }, status: :forbidden
|
||||
end
|
||||
|
||||
def set_custom_tool
|
||||
@custom_tool = account_custom_tools.find(params[:id])
|
||||
end
|
||||
@@ -32,6 +49,11 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
||||
@account_custom_tools ||= Current.account.captain_custom_tools
|
||||
end
|
||||
|
||||
def execute_test_request(tool)
|
||||
http_tool = Captain::Tools::HttpTool.new(nil, tool)
|
||||
http_tool.send(:execute_http_request, tool.endpoint_url, nil, nil)
|
||||
end
|
||||
|
||||
def custom_tool_params
|
||||
params.require(:custom_tool).permit(
|
||||
:title,
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
|
||||
#
|
||||
class Captain::CustomTool < ApplicationRecord
|
||||
class LimitExceededError < StandardError; end
|
||||
|
||||
MAX_PER_ACCOUNT = 15
|
||||
|
||||
include Concerns::Toolable
|
||||
include Concerns::SafeEndpointValidatable
|
||||
|
||||
@@ -31,6 +35,10 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
NAME_PREFIX = 'custom'.freeze
|
||||
NAME_SEPARATOR = '_'.freeze
|
||||
# OpenAI enforces a 64-char limit on function names. The slug is used
|
||||
# verbatim as the tool name in LLM requests, so it must fit within this limit.
|
||||
MAX_SLUG_LENGTH = 64
|
||||
COLLISION_SUFFIX_LENGTH = 7 # "_" + 6 random alphanumeric chars
|
||||
PARAM_SCHEMA_VALIDATION = {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
@@ -52,8 +60,9 @@ class Captain::CustomTool < ApplicationRecord
|
||||
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
|
||||
|
||||
before_validation :generate_slug
|
||||
before_create :ensure_within_limit
|
||||
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }
|
||||
validates :slug, presence: true, uniqueness: { scope: :account_id }, length: { maximum: MAX_SLUG_LENGTH }
|
||||
validates :title, presence: true
|
||||
validates :endpoint_url, presence: true
|
||||
validates_with JsonSchemaValidator,
|
||||
@@ -73,21 +82,29 @@ class Captain::CustomTool < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def ensure_within_limit
|
||||
# Lock the account row to serialize concurrent creates and prevent exceeding the cap
|
||||
Account.lock.find(account_id)
|
||||
return if account.captain_custom_tools.count < MAX_PER_ACCOUNT
|
||||
|
||||
raise LimitExceededError, I18n.t('captain.custom_tool.limit_exceeded', limit: MAX_PER_ACCOUNT)
|
||||
end
|
||||
|
||||
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}"
|
||||
parameterized_title = title.parameterize(separator: NAME_SEPARATOR)
|
||||
base_slug = "#{NAME_PREFIX}#{NAME_SEPARATOR}#{parameterized_title}".truncate(MAX_SLUG_LENGTH, omission: '')
|
||||
self.slug = find_unique_slug(base_slug)
|
||||
end
|
||||
|
||||
def find_unique_slug(base_slug)
|
||||
return base_slug unless slug_exists?(base_slug)
|
||||
|
||||
truncated = base_slug.truncate(MAX_SLUG_LENGTH - COLLISION_SUFFIX_LENGTH, omission: '')
|
||||
5.times do
|
||||
slug_candidate = "#{base_slug}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
slug_candidate = "#{truncated}#{NAME_SEPARATOR}#{SecureRandom.alphanumeric(6).downcase}"
|
||||
return slug_candidate unless slug_exists?(slug_candidate)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
module Concerns::Toolable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def tool(assistant)
|
||||
# Isolated namespace for user-defined custom tool classes.
|
||||
# Keeps them separate from built-in classes in Captain::Tools (e.g., HttpTool, CustomHttpTool).
|
||||
module CustomTools; end
|
||||
|
||||
def tool(assistant, base_class: Captain::Tools::HttpTool, **)
|
||||
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
|
||||
tool_slug = custom_tool_record.slug
|
||||
tool_class = Class.new(base_class) do
|
||||
description custom_tool_record.description
|
||||
|
||||
# Override name to use the slug directly, avoiding the namespace prefix
|
||||
# that RubyLLM's default normalization would produce (e.g., "captain--tools--custom_dog_facts").
|
||||
define_method(:name) { tool_slug }
|
||||
|
||||
custom_tool_record.param_schema.each do |param_def|
|
||||
param param_def['name'].to_sym,
|
||||
type: param_def['type'],
|
||||
@@ -18,17 +26,14 @@ module Concerns::Toolable
|
||||
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)
|
||||
# Register as a constant so the class gets a proper name (Class#name).
|
||||
# Anonymous classes return nil for #name, which causes "Invalid 'tools[].function.name':
|
||||
# empty string" errors from the LLM API. We use CustomTools as the namespace to avoid
|
||||
# collisions with real classes in Captain::Tools.
|
||||
CustomTools.send(:remove_const, class_name) if CustomTools.const_defined?(class_name, false)
|
||||
CustomTools.const_set(class_name, tool_class)
|
||||
|
||||
tool_class.new(assistant, self)
|
||||
tool_class.new(assistant, self, **)
|
||||
end
|
||||
|
||||
def build_request_url(params)
|
||||
|
||||
@@ -11,6 +11,10 @@ class Captain::CustomToolPolicy < ApplicationPolicy
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def test?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -30,7 +30,12 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
private
|
||||
|
||||
def build_tools
|
||||
[Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
tools = [Captain::Tools::SearchDocumentationService.new(@assistant, user: nil)]
|
||||
return tools unless custom_tools_enabled?
|
||||
|
||||
tools + @assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
ct.tool(@assistant, base_class: Captain::Tools::CustomHttpTool, conversation: @conversation)
|
||||
end
|
||||
end
|
||||
|
||||
def system_message
|
||||
@@ -38,11 +43,24 @@ class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
role: 'system',
|
||||
content: Captain::Llm::SystemPromptsService.assistant_response_generator(
|
||||
@assistant.name, @assistant.config['product_name'], @assistant.config,
|
||||
contact: contact_attributes
|
||||
contact: contact_attributes,
|
||||
custom_tools: custom_tools_metadata
|
||||
)
|
||||
}
|
||||
end
|
||||
|
||||
def custom_tools_metadata
|
||||
return [] unless custom_tools_enabled?
|
||||
|
||||
@assistant.account.captain_custom_tools.enabled.map do |ct|
|
||||
{ name: ct.slug, description: ct.description }
|
||||
end
|
||||
end
|
||||
|
||||
def custom_tools_enabled?
|
||||
@assistant.account.feature_enabled?('custom_tools')
|
||||
end
|
||||
|
||||
def contact_attributes
|
||||
return nil unless @conversation&.contact
|
||||
return nil unless @assistant&.feature_contact_attributes
|
||||
|
||||
@@ -152,7 +152,7 @@ class Captain::Llm::SystemPromptsService
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil)
|
||||
def assistant_response_generator(assistant_name, product_name, config = {}, contact: nil, custom_tools: [])
|
||||
assistant_citation_guidelines = if config['feature_citation']
|
||||
<<~CITATION_TEXT
|
||||
- Always include citations for any information provided, referencing the specific source (document only - skip if it was derived from a conversation).
|
||||
@@ -187,7 +187,7 @@ class Captain::Llm::SystemPromptsService
|
||||
#{assistant_citation_guidelines}
|
||||
|
||||
#{build_contact_context(contact)}[Task]
|
||||
Start by introducing yourself. Then, ask the user to share their question. When they answer, call the search_documentation function. Give a helpful response based on the steps written below.
|
||||
Start by introducing yourself. Then, ask the user to share their question. When they answer, use the most appropriate tool to find information. Give a helpful response based on the steps written below.
|
||||
|
||||
- Provide the user with the steps required to complete the action one by one.
|
||||
- Do not return list numbers in the steps, just the plain text is enough.
|
||||
@@ -203,6 +203,8 @@ class Captain::Llm::SystemPromptsService
|
||||
```
|
||||
- If the answer is not provided in context sections, Respond to the customer and ask whether they want to talk to another support agent . If they ask to Chat with another agent, return `conversation_handoff' as the response in JSON response
|
||||
#{'- You MUST provide numbered citations at the appropriate places in the text.' if config['feature_citation']}
|
||||
|
||||
#{build_tools_section(custom_tools)}
|
||||
SYSTEM_PROMPT_MESSAGE
|
||||
end
|
||||
|
||||
@@ -291,6 +293,15 @@ class Captain::Llm::SystemPromptsService
|
||||
|
||||
private
|
||||
|
||||
def build_tools_section(custom_tools)
|
||||
tools_list = custom_tools.map { |t| "- #{t[:name]}: #{t[:description]}" }.join("\n")
|
||||
<<~TOOLS.strip
|
||||
[Available Tools]
|
||||
- search_documentation: Search and retrieve documentation from knowledge base
|
||||
#{tools_list}
|
||||
TOOLS
|
||||
end
|
||||
|
||||
def build_contact_context(contact)
|
||||
return '' if contact.nil?
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# V1-compatible wrapper for custom HTTP tools.
|
||||
#
|
||||
# V2's HttpTool inherits from Agents::Tool which overrides execute(tool_context, **params),
|
||||
# making it incompatible with V1's RubyLLM pipeline that calls execute(**keyword_args).
|
||||
#
|
||||
# This class bridges the gap: it inherits from BaseTool (RubyLLM::Tool) for V1 compatibility
|
||||
# and delegates the actual HTTP execution to HttpTool#perform.
|
||||
class Captain::Tools::CustomHttpTool < Captain::Tools::BaseTool
|
||||
# BaseTool prepends Instrumentation, but our execute() shadows it in the MRO.
|
||||
# Re-prepend so Langfuse captures tool call input/output/timing.
|
||||
prepend Captain::Tools::Instrumentation
|
||||
|
||||
attr_reader :custom_tool
|
||||
|
||||
def initialize(assistant, custom_tool, conversation: nil)
|
||||
@custom_tool = custom_tool
|
||||
@conversation = conversation
|
||||
super(assistant)
|
||||
end
|
||||
|
||||
def active?
|
||||
@custom_tool.enabled?
|
||||
end
|
||||
|
||||
def execute(**params)
|
||||
http_tool = Captain::Tools::HttpTool.new(assistant, @custom_tool)
|
||||
http_tool.perform(build_tool_context, **params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_tool_context
|
||||
state = { account_id: assistant.account_id, assistant_id: assistant.id }
|
||||
add_conversation_state(state) if @conversation
|
||||
OpenStruct.new(state: state)
|
||||
end
|
||||
|
||||
def add_conversation_state(state)
|
||||
state[:conversation] = { id: @conversation.id, display_id: @conversation.display_id }
|
||||
state[:contact] = slice_record_attrs(@conversation.contact, :id, :email, :phone_number)
|
||||
state[:contact_inbox] = slice_record_attrs(@conversation.contact_inbox, :id, :hmac_verified)
|
||||
end
|
||||
|
||||
def slice_record_attrs(record, *keys)
|
||||
record&.attributes&.symbolize_keys&.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -17,7 +17,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
|
||||
linear_integration
|
||||
].freeze
|
||||
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment].freeze
|
||||
BUSINESS_PLAN_FEATURES = %w[sla custom_roles csat_review_notes conversation_required_attributes advanced_assignment custom_tools].freeze
|
||||
ENTERPRISE_PLAN_FEATURES = %w[audit_logs disable_branding saml].freeze
|
||||
PREMIUM_PLAN_FEATURES = (STARTUP_PLAN_FEATURES + BUSINESS_PLAN_FEATURES + ENTERPRISE_PLAN_FEATURES).freeze
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ 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.auth_config custom_tool.auth_config if Current.user&.administrator?
|
||||
json.param_schema custom_tool.param_schema
|
||||
json.enabled custom_tool.enabled
|
||||
json.account_id custom_tool.account_id
|
||||
|
||||
@@ -5,6 +5,8 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
let(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
before { account.enable_features!('custom_tools') }
|
||||
|
||||
def json_response
|
||||
JSON.parse(response.body, symbolize_names: true)
|
||||
end
|
||||
@@ -40,7 +42,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
expect(json_response[:payload].length).to eq(5)
|
||||
end
|
||||
|
||||
it 'returns only enabled custom tools' do
|
||||
it 'returns all custom tools including disabled' 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",
|
||||
@@ -48,8 +50,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CustomTools', type: :request do
|
||||
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)
|
||||
expect(json_response[:payload].length).to eq(2)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -187,6 +187,44 @@ RSpec.describe Attachment do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'set_extension' do
|
||||
it 'sets extension from filename on save' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to eq('pdf')
|
||||
end
|
||||
|
||||
it 'does not overwrite extension if already set' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file, extension: 'doc')
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to eq('doc')
|
||||
end
|
||||
|
||||
it 'handles filenames without extension' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake data'), filename: 'README', content_type: 'text/plain')
|
||||
attachment.save!
|
||||
|
||||
expect(attachment.extension).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data includes extension and content_type' do
|
||||
it 'returns extension and content_type for file attachments' do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
|
||||
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
|
||||
attachment.save!
|
||||
|
||||
event_data = attachment.push_event_data
|
||||
expect(event_data[:extension]).to eq('pdf')
|
||||
expect(event_data[:content_type]).to eq('application/pdf')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'file size validation' do
|
||||
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user