mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 19:17:48 +00:00 
			
		
		
		
	Merge branch 'develop' into chore/partipitant-conversations
This commit is contained in:
		
							
								
								
									
										36
									
								
								app/javascript/dashboard/api/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								app/javascript/dashboard/api/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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(); | ||||||
| @@ -10,6 +10,10 @@ const props = defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   translationKey: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|   entity: { |   entity: { | ||||||
|     type: Object, |     type: Object, | ||||||
|     required: true, |     required: true, | ||||||
| @@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']); | |||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
| const store = useStore(); | const store = useStore(); | ||||||
| const deleteDialogRef = ref(null); | const deleteDialogRef = ref(null); | ||||||
| const i18nKey = computed(() => props.type.toUpperCase()); | const i18nKey = computed(() => { | ||||||
|  |   return props.translationKey || props.type.toUpperCase(); | ||||||
|  | }); | ||||||
|  |  | ||||||
| const deleteEntity = async payload => { | const deleteEntity = async payload => { | ||||||
|   if (!payload) return; |   if (!payload) return; | ||||||
|   | |||||||
| @@ -0,0 +1,73 @@ | |||||||
|  | <script setup> | ||||||
|  | import { defineModel, watch } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   authType: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |     validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const authConfig = defineModel('authConfig', { | ||||||
|  |   type: Object, | ||||||
|  |   default: () => ({}), | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.authType, | ||||||
|  |   () => { | ||||||
|  |     authConfig.value = {}; | ||||||
|  |   } | ||||||
|  | ); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="flex flex-col gap-2"> | ||||||
|  |     <Input | ||||||
|  |       v-if="authType === 'bearer'" | ||||||
|  |       v-model="authConfig.token" | ||||||
|  |       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')" | ||||||
|  |       :placeholder=" | ||||||
|  |         t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER') | ||||||
|  |       " | ||||||
|  |     /> | ||||||
|  |     <template v-else-if="authType === 'basic'"> | ||||||
|  |       <Input | ||||||
|  |         v-model="authConfig.username" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')" | ||||||
|  |         :placeholder=" | ||||||
|  |           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER') | ||||||
|  |         " | ||||||
|  |       /> | ||||||
|  |       <Input | ||||||
|  |         v-model="authConfig.password" | ||||||
|  |         type="password" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')" | ||||||
|  |         :placeholder=" | ||||||
|  |           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER') | ||||||
|  |         " | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |     <template v-else-if="authType === 'api_key'"> | ||||||
|  |       <Input | ||||||
|  |         v-model="authConfig.name" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')" | ||||||
|  |         :placeholder=" | ||||||
|  |           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER') | ||||||
|  |         " | ||||||
|  |       /> | ||||||
|  |       <Input | ||||||
|  |         v-model="authConfig.key" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')" | ||||||
|  |         :placeholder=" | ||||||
|  |           t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER') | ||||||
|  |         " | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,87 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; | ||||||
|  |  | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  | import CustomToolForm from './CustomToolForm.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   selectedTool: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  |   type: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'create', | ||||||
|  |     validator: value => ['create', 'edit'].includes(value), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['close']); | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  |  | ||||||
|  | const dialogRef = ref(null); | ||||||
|  |  | ||||||
|  | const updateTool = toolDetails => | ||||||
|  |   store.dispatch('captainCustomTools/update', { | ||||||
|  |     id: props.selectedTool.id, | ||||||
|  |     ...toolDetails, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  | const i18nKey = computed( | ||||||
|  |   () => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}` | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const createTool = toolDetails => | ||||||
|  |   store.dispatch('captainCustomTools/create', toolDetails); | ||||||
|  |  | ||||||
|  | const handleSubmit = async updatedTool => { | ||||||
|  |   try { | ||||||
|  |     if (props.type === 'edit') { | ||||||
|  |       await updateTool(updatedTool); | ||||||
|  |     } else { | ||||||
|  |       await createTool(updatedTool); | ||||||
|  |     } | ||||||
|  |     useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`)); | ||||||
|  |     dialogRef.value.close(); | ||||||
|  |   } catch (error) { | ||||||
|  |     const errorMessage = | ||||||
|  |       parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`); | ||||||
|  |     useAlert(errorMessage); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleClose = () => { | ||||||
|  |   emit('close'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleCancel = () => { | ||||||
|  |   dialogRef.value.close(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="dialogRef" | ||||||
|  |     width="2xl" | ||||||
|  |     :title="$t(`${i18nKey}.TITLE`)" | ||||||
|  |     :description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')" | ||||||
|  |     :show-cancel-button="false" | ||||||
|  |     :show-confirm-button="false" | ||||||
|  |     @close="handleClose" | ||||||
|  |   > | ||||||
|  |     <CustomToolForm | ||||||
|  |       :mode="type" | ||||||
|  |       :tool="selectedTool" | ||||||
|  |       @submit="handleSubmit" | ||||||
|  |       @cancel="handleCancel" | ||||||
|  |     /> | ||||||
|  |     <template #footer /> | ||||||
|  |   </Dialog> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,125 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed } from 'vue'; | ||||||
|  | import { useToggle } from '@vueuse/core'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { dynamicTime } from 'shared/helpers/timeHelper'; | ||||||
|  |  | ||||||
|  | import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||||
|  | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Policy from 'dashboard/components/policy.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   id: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   title: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   description: { | ||||||
|  |     type: String, | ||||||
|  |     default: '', | ||||||
|  |   }, | ||||||
|  |   authType: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'none', | ||||||
|  |   }, | ||||||
|  |   updatedAt: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   createdAt: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['action']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const [showActionsDropdown, toggleDropdown] = useToggle(); | ||||||
|  |  | ||||||
|  | const menuItems = computed(() => [ | ||||||
|  |   { | ||||||
|  |     label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'), | ||||||
|  |     value: 'edit', | ||||||
|  |     action: 'edit', | ||||||
|  |     icon: 'i-lucide-pencil-line', | ||||||
|  |   }, | ||||||
|  |   { | ||||||
|  |     label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'), | ||||||
|  |     value: 'delete', | ||||||
|  |     action: 'delete', | ||||||
|  |     icon: 'i-lucide-trash', | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const timestamp = computed(() => | ||||||
|  |   dynamicTime(props.updatedAt || props.createdAt) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const handleAction = ({ action, value }) => { | ||||||
|  |   toggleDropdown(false); | ||||||
|  |   emit('action', { action, value, id: props.id }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const authTypeLabel = computed(() => { | ||||||
|  |   return t( | ||||||
|  |     `CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}` | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <CardLayout class="relative"> | ||||||
|  |     <div class="flex relative justify-between w-full gap-1"> | ||||||
|  |       <span class="text-base text-n-slate-12 line-clamp-1 font-medium"> | ||||||
|  |         {{ title }} | ||||||
|  |       </span> | ||||||
|  |       <div class="flex items-center gap-2"> | ||||||
|  |         <Policy | ||||||
|  |           v-on-clickaway="() => toggleDropdown(false)" | ||||||
|  |           :permissions="['administrator']" | ||||||
|  |           class="relative flex items-center group" | ||||||
|  |         > | ||||||
|  |           <Button | ||||||
|  |             icon="i-lucide-ellipsis-vertical" | ||||||
|  |             color="slate" | ||||||
|  |             size="xs" | ||||||
|  |             class="rounded-md group-hover:bg-n-alpha-2" | ||||||
|  |             @click="toggleDropdown()" | ||||||
|  |           /> | ||||||
|  |           <DropdownMenu | ||||||
|  |             v-if="showActionsDropdown" | ||||||
|  |             :menu-items="menuItems" | ||||||
|  |             class="mt-1 ltr:right-0 rtl:right-0 top-full" | ||||||
|  |             @action="handleAction($event)" | ||||||
|  |           /> | ||||||
|  |         </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" | ||||||
|  |         > | ||||||
|  |           {{ description }} | ||||||
|  |         </span> | ||||||
|  |         <span | ||||||
|  |           v-if="authType !== 'none'" | ||||||
|  |           class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1" | ||||||
|  |         > | ||||||
|  |           <i class="i-lucide-lock text-base" /> | ||||||
|  |           {{ authTypeLabel }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |       <span class="text-sm text-n-slate-11 line-clamp-1 shrink-0"> | ||||||
|  |         {{ timestamp }} | ||||||
|  |       </span> | ||||||
|  |     </div> | ||||||
|  |   </CardLayout> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,271 @@ | |||||||
|  | <script setup> | ||||||
|  | import { reactive, computed, useTemplateRef, watch } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { required } from '@vuelidate/validators'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store'; | ||||||
|  |  | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||||
|  | import ParamRow from './ParamRow.vue'; | ||||||
|  | import AuthConfig from './AuthConfig.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   mode: { | ||||||
|  |     type: String, | ||||||
|  |     default: 'create', | ||||||
|  |     validator: value => ['create', 'edit'].includes(value), | ||||||
|  |   }, | ||||||
|  |   tool: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['submit', 'cancel']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const formState = { | ||||||
|  |   uiFlags: useMapGetter('captainCustomTools/getUIFlags'), | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const initialState = { | ||||||
|  |   title: '', | ||||||
|  |   description: '', | ||||||
|  |   endpoint_url: '', | ||||||
|  |   http_method: 'GET', | ||||||
|  |   request_template: '', | ||||||
|  |   response_template: '', | ||||||
|  |   auth_type: 'none', | ||||||
|  |   auth_config: {}, | ||||||
|  |   param_schema: [], | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const state = reactive({ ...initialState }); | ||||||
|  |  | ||||||
|  | // Populate form when in edit mode | ||||||
|  | watch( | ||||||
|  |   () => props.tool, | ||||||
|  |   newTool => { | ||||||
|  |     if (props.mode === 'edit' && newTool && newTool.id) { | ||||||
|  |       state.title = newTool.title || ''; | ||||||
|  |       state.description = newTool.description || ''; | ||||||
|  |       state.endpoint_url = newTool.endpoint_url || ''; | ||||||
|  |       state.http_method = newTool.http_method || 'GET'; | ||||||
|  |       state.request_template = newTool.request_template || ''; | ||||||
|  |       state.response_template = newTool.response_template || ''; | ||||||
|  |       state.auth_type = newTool.auth_type || 'none'; | ||||||
|  |       state.auth_config = newTool.auth_config || {}; | ||||||
|  |       state.param_schema = newTool.param_schema || []; | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   { immediate: true } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const DEFAULT_PARAM = { | ||||||
|  |   name: '', | ||||||
|  |   type: 'string', | ||||||
|  |   description: '', | ||||||
|  |   required: false, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const validationRules = { | ||||||
|  |   title: { required }, | ||||||
|  |   endpoint_url: { required }, | ||||||
|  |   http_method: { required }, | ||||||
|  |   auth_type: { required }, | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const httpMethodOptions = computed(() => [ | ||||||
|  |   { value: 'GET', label: 'GET' }, | ||||||
|  |   { value: 'POST', label: 'POST' }, | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const authTypeOptions = computed(() => [ | ||||||
|  |   { value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') }, | ||||||
|  |   { value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') }, | ||||||
|  |   { value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') }, | ||||||
|  |   { | ||||||
|  |     value: 'api_key', | ||||||
|  |     label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'), | ||||||
|  |   }, | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate(validationRules, state); | ||||||
|  |  | ||||||
|  | const isLoading = computed(() => | ||||||
|  |   props.mode === 'edit' | ||||||
|  |     ? formState.uiFlags.value.updatingItem | ||||||
|  |     : formState.uiFlags.value.creatingItem | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const getErrorMessage = (field, errorKey) => { | ||||||
|  |   return v$.value[field].$error | ||||||
|  |     ? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`) | ||||||
|  |     : ''; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const formErrors = computed(() => ({ | ||||||
|  |   title: getErrorMessage('title', 'TITLE'), | ||||||
|  |   endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'), | ||||||
|  | })); | ||||||
|  |  | ||||||
|  | const paramsRef = useTemplateRef('paramsRef'); | ||||||
|  |  | ||||||
|  | const isParamsValid = () => { | ||||||
|  |   if (!paramsRef.value || paramsRef.value.length === 0) { | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  |   return paramsRef.value.every(param => param.validate()); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const removeParam = index => { | ||||||
|  |   state.param_schema.splice(index, 1); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const addParam = () => { | ||||||
|  |   state.param_schema.push({ ...DEFAULT_PARAM }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleCancel = () => emit('cancel'); | ||||||
|  |  | ||||||
|  | const handleSubmit = async () => { | ||||||
|  |   const isFormValid = await v$.value.$validate(); | ||||||
|  |   if (!isFormValid || !isParamsValid()) { | ||||||
|  |     return; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   emit('submit', state); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <form | ||||||
|  |     class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll" | ||||||
|  |     @submit.prevent="handleSubmit" | ||||||
|  |   > | ||||||
|  |     <Input | ||||||
|  |       v-model="state.title" | ||||||
|  |       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')" | ||||||
|  |       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')" | ||||||
|  |       :message="formErrors.title" | ||||||
|  |       :message-type="formErrors.title ? 'error' : 'info'" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <TextArea | ||||||
|  |       v-model="state.description" | ||||||
|  |       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')" | ||||||
|  |       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')" | ||||||
|  |       :rows="2" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <div class="flex gap-2"> | ||||||
|  |       <div class="flex flex-col gap-1 w-28"> | ||||||
|  |         <label class="mb-0.5 text-sm font-medium text-n-slate-12"> | ||||||
|  |           {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }} | ||||||
|  |         </label> | ||||||
|  |         <ComboBox | ||||||
|  |           v-model="state.http_method" | ||||||
|  |           :options="httpMethodOptions" | ||||||
|  |           class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <Input | ||||||
|  |         v-model="state.endpoint_url" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')" | ||||||
|  |         :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')" | ||||||
|  |         :message="formErrors.endpoint_url" | ||||||
|  |         :message-type="formErrors.endpoint_url ? 'error' : 'info'" | ||||||
|  |         class="flex-1" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div class="flex flex-col gap-1"> | ||||||
|  |       <label class="mb-0.5 text-sm font-medium text-n-slate-12"> | ||||||
|  |         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }} | ||||||
|  |       </label> | ||||||
|  |       <ComboBox | ||||||
|  |         v-model="state.auth_type" | ||||||
|  |         :options="authTypeOptions" | ||||||
|  |         class="[&>div>button]:bg-n-alpha-black2" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <AuthConfig | ||||||
|  |       v-model:auth-config="state.auth_config" | ||||||
|  |       :auth-type="state.auth_type" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <div class="flex flex-col gap-2"> | ||||||
|  |       <label class="text-sm font-medium text-n-slate-12"> | ||||||
|  |         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }} | ||||||
|  |       </label> | ||||||
|  |       <p class="text-xs text-n-slate-11 -mt-1"> | ||||||
|  |         {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }} | ||||||
|  |       </p> | ||||||
|  |       <ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none"> | ||||||
|  |         <ParamRow | ||||||
|  |           v-for="(param, index) in state.param_schema" | ||||||
|  |           :key="index" | ||||||
|  |           ref="paramsRef" | ||||||
|  |           v-model:name="param.name" | ||||||
|  |           v-model:type="param.type" | ||||||
|  |           v-model:description="param.description" | ||||||
|  |           v-model:required="param.required" | ||||||
|  |           @remove="removeParam(index)" | ||||||
|  |         /> | ||||||
|  |       </ul> | ||||||
|  |       <Button | ||||||
|  |         type="button" | ||||||
|  |         sm | ||||||
|  |         ghost | ||||||
|  |         blue | ||||||
|  |         icon="i-lucide-plus" | ||||||
|  |         :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')" | ||||||
|  |         @click="addParam" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <TextArea | ||||||
|  |       v-if="state.http_method === 'POST'" | ||||||
|  |       v-model="state.request_template" | ||||||
|  |       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')" | ||||||
|  |       :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')" | ||||||
|  |       :rows="4" | ||||||
|  |       class="[&_textarea]:font-mono" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <TextArea | ||||||
|  |       v-model="state.response_template" | ||||||
|  |       :label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')" | ||||||
|  |       :placeholder=" | ||||||
|  |         t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER') | ||||||
|  |       " | ||||||
|  |       :rows="4" | ||||||
|  |       class="[&_textarea]:font-mono" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|  |     <div class="flex gap-3 justify-between items-center w-full"> | ||||||
|  |       <Button | ||||||
|  |         type="button" | ||||||
|  |         variant="faded" | ||||||
|  |         color="slate" | ||||||
|  |         :label="t('CAPTAIN.FORM.CANCEL')" | ||||||
|  |         class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3" | ||||||
|  |         @click="handleCancel" | ||||||
|  |       /> | ||||||
|  |       <Button | ||||||
|  |         type="submit" | ||||||
|  |         :label=" | ||||||
|  |           t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE') | ||||||
|  |         " | ||||||
|  |         class="w-full" | ||||||
|  |         :is-loading="isLoading" | ||||||
|  |         :disabled="isLoading" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,113 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, defineModel, ref, watch } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue'; | ||||||
|  | import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['remove']); | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const showErrors = ref(false); | ||||||
|  |  | ||||||
|  | const name = defineModel('name', { | ||||||
|  |   type: String, | ||||||
|  |   required: true, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const type = defineModel('type', { | ||||||
|  |   type: String, | ||||||
|  |   required: true, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const description = defineModel('description', { | ||||||
|  |   type: String, | ||||||
|  |   default: '', | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const required = defineModel('required', { | ||||||
|  |   type: Boolean, | ||||||
|  |   default: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const paramTypeOptions = computed(() => [ | ||||||
|  |   { value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') }, | ||||||
|  |   { value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') }, | ||||||
|  |   { | ||||||
|  |     value: 'boolean', | ||||||
|  |     label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'), | ||||||
|  |   }, | ||||||
|  |   { value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') }, | ||||||
|  |   { value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') }, | ||||||
|  | ]); | ||||||
|  |  | ||||||
|  | const validationError = computed(() => { | ||||||
|  |   if (!name.value || name.value.trim() === '') { | ||||||
|  |     return 'PARAM_NAME_REQUIRED'; | ||||||
|  |   } | ||||||
|  |   return null; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | watch([name, type, description, required], () => { | ||||||
|  |   showErrors.value = false; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const validate = () => { | ||||||
|  |   showErrors.value = true; | ||||||
|  |   return !validationError.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ validate }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <li class="list-none"> | ||||||
|  |     <div | ||||||
|  |       class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2" | ||||||
|  |       :class="{ | ||||||
|  |         'animate-wiggle border-n-ruby-9': showErrors && validationError, | ||||||
|  |       }" | ||||||
|  |     > | ||||||
|  |       <div class="flex flex-col flex-1 gap-3"> | ||||||
|  |         <div class="grid grid-cols-3 gap-2"> | ||||||
|  |           <Input | ||||||
|  |             v-model="name" | ||||||
|  |             :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')" | ||||||
|  |             class="col-span-2" | ||||||
|  |           /> | ||||||
|  |           <ComboBox | ||||||
|  |             v-model="type" | ||||||
|  |             :options="paramTypeOptions" | ||||||
|  |             :placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')" | ||||||
|  |             class="[&>div>button]:bg-n-alpha-black2" | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |         <Input | ||||||
|  |           v-model="description" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |         /> | ||||||
|  |         <label class="flex items-center gap-2 cursor-pointer"> | ||||||
|  |           <Checkbox v-model="required" /> | ||||||
|  |           <span class="text-sm text-n-slate-11"> | ||||||
|  |             {{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }} | ||||||
|  |           </span> | ||||||
|  |         </label> | ||||||
|  |       </div> | ||||||
|  |       <Button | ||||||
|  |         solid | ||||||
|  |         slate | ||||||
|  |         icon="i-lucide-trash" | ||||||
|  |         class="flex-shrink-0" | ||||||
|  |         @click.stop="emit('remove')" | ||||||
|  |       /> | ||||||
|  |     </div> | ||||||
|  |     <span | ||||||
|  |       v-if="showErrors && validationError" | ||||||
|  |       class="block mt-1 text-sm text-n-ruby-11" | ||||||
|  |     > | ||||||
|  |       {{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }} | ||||||
|  |     </span> | ||||||
|  |   </li> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,29 @@ | |||||||
|  | <script setup> | ||||||
|  | import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['click']); | ||||||
|  |  | ||||||
|  | const onClick = () => { | ||||||
|  |   emit('click'); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <EmptyStateLayout | ||||||
|  |     :title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')" | ||||||
|  |     :subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')" | ||||||
|  |     :action-perms="['administrator']" | ||||||
|  |   > | ||||||
|  |     <template #empty-state-item> | ||||||
|  |       <div class="min-h-[600px]" /> | ||||||
|  |     </template> | ||||||
|  |     <template #actions> | ||||||
|  |       <Button | ||||||
|  |         :label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" | ||||||
|  |         icon="i-lucide-plus" | ||||||
|  |         @click="onClick" | ||||||
|  |       /> | ||||||
|  |     </template> | ||||||
|  |   </EmptyStateLayout> | ||||||
|  | </template> | ||||||
| @@ -238,6 +238,11 @@ const menuItems = computed(() => { | |||||||
|           label: t('SIDEBAR.CAPTAIN_RESPONSES'), |           label: t('SIDEBAR.CAPTAIN_RESPONSES'), | ||||||
|           to: accountScopedRoute('captain_responses_index'), |           to: accountScopedRoute('captain_responses_index'), | ||||||
|         }, |         }, | ||||||
|  |         { | ||||||
|  |           name: 'Tools', | ||||||
|  |           label: t('SIDEBAR.CAPTAIN_TOOLS'), | ||||||
|  |           to: accountScopedRoute('captain_tools_index'), | ||||||
|  |         }, | ||||||
|       ], |       ], | ||||||
|     }, |     }, | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -49,6 +49,5 @@ export const PREMIUM_FEATURES = [ | |||||||
|   FEATURE_FLAGS.CUSTOM_ROLES, |   FEATURE_FLAGS.CUSTOM_ROLES, | ||||||
|   FEATURE_FLAGS.AUDIT_LOGS, |   FEATURE_FLAGS.AUDIT_LOGS, | ||||||
|   FEATURE_FLAGS.HELP_CENTER, |   FEATURE_FLAGS.HELP_CENTER, | ||||||
|   FEATURE_FLAGS.CAPTAIN_V2, |  | ||||||
|   FEATURE_FLAGS.SAML, |   FEATURE_FLAGS.SAML, | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -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": { |     "RESPONSES": { | ||||||
|       "HEADER": "FAQs", |       "HEADER": "FAQs", | ||||||
|       "ADD_NEW": "Create new FAQ", |       "ADD_NEW": "Create new FAQ", | ||||||
|   | |||||||
| @@ -304,6 +304,7 @@ | |||||||
|     "CAPTAIN_ASSISTANTS": "Assistants", |     "CAPTAIN_ASSISTANTS": "Assistants", | ||||||
|     "CAPTAIN_DOCUMENTS": "Documents", |     "CAPTAIN_DOCUMENTS": "Documents", | ||||||
|     "CAPTAIN_RESPONSES": "FAQs", |     "CAPTAIN_RESPONSES": "FAQs", | ||||||
|  |     "CAPTAIN_TOOLS": "Tools", | ||||||
|     "HOME": "Home", |     "HOME": "Home", | ||||||
|     "AGENTS": "Agents", |     "AGENTS": "Agents", | ||||||
|     "AGENT_BOTS": "Bots", |     "AGENT_BOTS": "Bots", | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue'; | |||||||
| import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; | import AssistantScenariosIndex from './assistants/scenarios/Index.vue'; | ||||||
| import DocumentsIndex from './documents/Index.vue'; | import DocumentsIndex from './documents/Index.vue'; | ||||||
| import ResponsesIndex from './responses/Index.vue'; | import ResponsesIndex from './responses/Index.vue'; | ||||||
|  | import CustomToolsIndex from './tools/Index.vue'; | ||||||
|  |  | ||||||
| export const routes = [ | 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, | ||||||
|  |       ], | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
| ]; | ]; | ||||||
|   | |||||||
| @@ -0,0 +1,138 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, onMounted, ref, nextTick } from 'vue'; | ||||||
|  | import { useMapGetter, useStore } from 'dashboard/composables/store'; | ||||||
|  | import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||||
|  |  | ||||||
|  | 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 uiFlags = useMapGetter('captainCustomTools/getUIFlags'); | ||||||
|  | const customTools = useMapGetter('captainCustomTools/getRecords'); | ||||||
|  | const isFetching = computed(() => uiFlags.value.fetchingList); | ||||||
|  | const customToolsMeta = useMapGetter('captainCustomTools/getMeta'); | ||||||
|  |  | ||||||
|  | const createDialogRef = ref(null); | ||||||
|  | const deleteDialogRef = ref(null); | ||||||
|  | const selectedTool = ref(null); | ||||||
|  | const dialogType = ref(''); | ||||||
|  |  | ||||||
|  | const fetchCustomTools = (page = 1) => { | ||||||
|  |   store.dispatch('captainCustomTools/get', { page }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onPageChange = page => fetchCustomTools(page); | ||||||
|  |  | ||||||
|  | const openCreateDialog = () => { | ||||||
|  |   dialogType.value = 'create'; | ||||||
|  |   selectedTool.value = null; | ||||||
|  |   nextTick(() => createDialogRef.value.dialogRef.open()); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleEdit = tool => { | ||||||
|  |   dialogType.value = 'edit'; | ||||||
|  |   selectedTool.value = tool; | ||||||
|  |   nextTick(() => createDialogRef.value.dialogRef.open()); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDelete = tool => { | ||||||
|  |   selectedTool.value = tool; | ||||||
|  |   nextTick(() => deleteDialogRef.value.dialogRef.open()); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleAction = ({ action, id }) => { | ||||||
|  |   const tool = customTools.value.find(t => t.id === id); | ||||||
|  |   if (action === 'edit') { | ||||||
|  |     handleEdit(tool); | ||||||
|  |   } else if (action === 'delete') { | ||||||
|  |     handleDelete(tool); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDialogClose = () => { | ||||||
|  |   dialogType.value = ''; | ||||||
|  |   selectedTool.value = null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onDeleteSuccess = () => { | ||||||
|  |   selectedTool.value = null; | ||||||
|  |   // Check if page will be empty after deletion | ||||||
|  |   if (customTools.value.length === 1 && customToolsMeta.value.page > 1) { | ||||||
|  |     // Go to previous page if current page will be empty | ||||||
|  |     onPageChange(customToolsMeta.value.page - 1); | ||||||
|  |   } else { | ||||||
|  |     // Refresh current page | ||||||
|  |     fetchCustomTools(customToolsMeta.value.page); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(() => { | ||||||
|  |   fetchCustomTools(); | ||||||
|  | }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <PageLayout | ||||||
|  |     :header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')" | ||||||
|  |     :button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" | ||||||
|  |     :button-policy="['administrator']" | ||||||
|  |     :total-count="customToolsMeta.totalCount" | ||||||
|  |     :current-page="customToolsMeta.page" | ||||||
|  |     :show-pagination-footer="!isFetching && !!customTools.length" | ||||||
|  |     :is-fetching="isFetching" | ||||||
|  |     :is-empty="!customTools.length" | ||||||
|  |     :feature-flag="FEATURE_FLAGS.CAPTAIN_V2" | ||||||
|  |     @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"> | ||||||
|  |         <CustomToolCard | ||||||
|  |           v-for="tool in customTools" | ||||||
|  |           :id="tool.id" | ||||||
|  |           :key="tool.id" | ||||||
|  |           :title="tool.title" | ||||||
|  |           :description="tool.description" | ||||||
|  |           :endpoint-url="tool.endpoint_url" | ||||||
|  |           :http-method="tool.http_method" | ||||||
|  |           :auth-type="tool.auth_type" | ||||||
|  |           :param-schema="tool.param_schema" | ||||||
|  |           :enabled="tool.enabled" | ||||||
|  |           :created-at="tool.created_at" | ||||||
|  |           :updated-at="tool.updated_at" | ||||||
|  |           @action="handleAction" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     </template> | ||||||
|  |   </PageLayout> | ||||||
|  |  | ||||||
|  |   <CreateCustomToolDialog | ||||||
|  |     v-if="dialogType" | ||||||
|  |     ref="createDialogRef" | ||||||
|  |     :type="dialogType" | ||||||
|  |     :selected-tool="selectedTool" | ||||||
|  |     @close="handleDialogClose" | ||||||
|  |   /> | ||||||
|  |  | ||||||
|  |   <DeleteDialog | ||||||
|  |     v-if="selectedTool" | ||||||
|  |     ref="deleteDialogRef" | ||||||
|  |     :entity="selectedTool" | ||||||
|  |     type="CustomTools" | ||||||
|  |     translation-key="CUSTOM_TOOLS" | ||||||
|  |     @delete-success="onDeleteSuccess" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
							
								
								
									
										35
									
								
								app/javascript/dashboard/store/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								app/javascript/dashboard/store/captain/customTools.js
									
									
									
									
									
										Normal file
									
								
							| @@ -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); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |   }), | ||||||
|  | }); | ||||||
| @@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools'; | |||||||
| import { throwErrorMessage } from 'dashboard/store/utils/api'; | import { throwErrorMessage } from 'dashboard/store/utils/api'; | ||||||
|  |  | ||||||
| const toolsStore = createStore({ | const toolsStore = createStore({ | ||||||
|   name: 'captainTool', |   name: 'Tools', | ||||||
|   API: CaptainToolsAPI, |   API: CaptainToolsAPI, | ||||||
|   actions: mutations => ({ |   actions: mutations => ({ | ||||||
|     getTools: async ({ commit }) => { |     getTools: async ({ commit }) => { | ||||||
|   | |||||||
| @@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads'; | |||||||
| import copilotMessages from './captain/copilotMessages'; | import copilotMessages from './captain/copilotMessages'; | ||||||
| import captainScenarios from './captain/scenarios'; | import captainScenarios from './captain/scenarios'; | ||||||
| import captainTools from './captain/tools'; | import captainTools from './captain/tools'; | ||||||
|  | import captainCustomTools from './captain/customTools'; | ||||||
|  |  | ||||||
| const plugins = []; | const plugins = []; | ||||||
|  |  | ||||||
| @@ -119,6 +120,7 @@ export default createStore({ | |||||||
|     copilotMessages, |     copilotMessages, | ||||||
|     captainScenarios, |     captainScenarios, | ||||||
|     captainTools, |     captainTools, | ||||||
|  |     captainCustomTools, | ||||||
|   }, |   }, | ||||||
|   plugins, |   plugins, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -17,7 +17,6 @@ | |||||||
| #  index_custom_filters_on_user_id     (user_id) | #  index_custom_filters_on_user_id     (user_id) | ||||||
| # | # | ||||||
| class CustomFilter < ApplicationRecord | class CustomFilter < ApplicationRecord | ||||||
|   MAX_FILTER_PER_USER = 50 |  | ||||||
|   belongs_to :user |   belongs_to :user | ||||||
|   belongs_to :account |   belongs_to :account | ||||||
|  |  | ||||||
| @@ -25,7 +24,7 @@ class CustomFilter < ApplicationRecord | |||||||
|   validate :validate_number_of_filters |   validate :validate_number_of_filters | ||||||
|  |  | ||||||
|   def 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') |     errors.add :account_id, I18n.t('errors.custom_filters.number_of_records') | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -15,6 +15,7 @@ Rails.application.config.after_initialize do | |||||||
|         config.openai_api_base = api_base |         config.openai_api_base = api_base | ||||||
|       end |       end | ||||||
|       config.default_model = model |       config.default_model = model | ||||||
|  |       config.max_turns = 30 | ||||||
|       config.debug = false |       config.debug = false | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -100,7 +100,7 @@ en: | |||||||
|       validations: |       validations: | ||||||
|         name: should not start or end with symbols, and it should not have < > / \ @ characters. |         name: should not start or end with symbols, and it should not have < > / \ @ characters. | ||||||
|     custom_filters: |     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_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_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}]. | ||||||
|       invalid_query_operator: Query operator must be either "AND" or "OR". |       invalid_query_operator: Query operator must be either "AND" or "OR". | ||||||
| @@ -336,6 +336,8 @@ en: | |||||||
|       processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' |       processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})' | ||||||
|       chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' |       chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}' | ||||||
|       page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' |       page_processing_error: 'Error processing pages %{start}-%{end}: %{error}' | ||||||
|  |     custom_tool: | ||||||
|  |       slug_generation_failed: 'Unable to generate unique slug after 5 attempts' | ||||||
|   public_portal: |   public_portal: | ||||||
|     search: |     search: | ||||||
|       search_placeholder: Search for article by title or body... |       search_placeholder: Search for article by title or body... | ||||||
|   | |||||||
| @@ -67,6 +67,7 @@ Rails.application.routes.draw do | |||||||
|             resources :copilot_threads, only: [:index, :create] do |             resources :copilot_threads, only: [:index, :create] do | ||||||
|               resources :copilot_messages, only: [:index, :create] |               resources :copilot_messages, only: [:index, :create] | ||||||
|             end |             end | ||||||
|  |             resources :custom_tools | ||||||
|             resources :documents, only: [:index, :show, :create, :destroy] |             resources :documents, only: [:index, :show, :create, :destroy] | ||||||
|           end |           end | ||||||
|           resource :saml_settings, only: [:show, :create, :update, :destroy] |           resource :saml_settings, only: [:show, :create, :update, :destroy] | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								db/migrate/20251003091242_create_captain_custom_tools.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								db/migrate/20251003091242_create_captain_custom_tools.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								db/schema.rb
									
									
									
									
									
								
							| @@ -10,7 +10,7 @@ | |||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # 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 |   # These extensions should be enabled to support this database | ||||||
|   enable_extension "pg_stat_statements" |   enable_extension "pg_stat_statements" | ||||||
|   enable_extension "pg_trgm" |   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" |     t.index ["account_id"], name: "index_captain_assistants_on_account_id" | ||||||
|   end |   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| |   create_table "captain_documents", force: :cascade do |t| | ||||||
|     t.string "name" |     t.string "name" | ||||||
|     t.string "external_link", null: false |     t.string "external_link", null: false | ||||||
|   | |||||||
| @@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def tools |   def tools | ||||||
|     @tools = Captain::Assistant.available_agent_tools |     assistant = Captain::Assistant.new(account: Current.account) | ||||||
|  |     @tools = assistant.available_agent_tools | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   private |   private | ||||||
|   | |||||||
| @@ -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 | ||||||
| @@ -49,10 +49,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob | |||||||
|       .where(message_type: [:incoming, :outgoing]) |       .where(message_type: [:incoming, :outgoing]) | ||||||
|       .where(private: false) |       .where(private: false) | ||||||
|       .map do |message| |       .map do |message| | ||||||
|       { |       message_hash = { | ||||||
|         content: prepare_multimodal_message_content(message), |         content: prepare_multimodal_message_content(message), | ||||||
|         role: determine_role(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 | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -79,25 +84,31 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def create_handoff_message |   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 |   end | ||||||
|  |  | ||||||
|   def create_messages |   def create_messages | ||||||
|     validate_message_content!(@response['response']) |     validate_message_content!(@response['response']) | ||||||
|     create_outgoing_message(@response['response']) |     create_outgoing_message(@response['response'], agent_name: @response['agent_name']) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def validate_message_content!(content) |   def validate_message_content!(content) | ||||||
|     raise ArgumentError, 'Message content cannot be blank' if content.blank? |     raise ArgumentError, 'Message content cannot be blank' if content.blank? | ||||||
|   end |   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!( |     @conversation.messages.create!( | ||||||
|       message_type: :outgoing, |       message_type: :outgoing, | ||||||
|       account_id: account.id, |       account_id: account.id, | ||||||
|       inbox_id: inbox.id, |       inbox_id: inbox.id, | ||||||
|       sender: @assistant, |       sender: @assistant, | ||||||
|       content: message_content |       content: message_content, | ||||||
|  |       additional_attributes: additional_attrs | ||||||
|     ) |     ) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   | |||||||
| @@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord | |||||||
|     name |     name | ||||||
|   end |   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 |   def push_event_data | ||||||
|     { |     { | ||||||
|       id: id, |       id: id, | ||||||
| @@ -92,6 +105,7 @@ class Captain::Assistant < ApplicationRecord | |||||||
|       product_name: config['product_name'] || 'this product', |       product_name: config['product_name'] || 'this product', | ||||||
|       scenarios: scenarios.enabled.map do |scenario| |       scenarios: scenarios.enabled.map do |scenario| | ||||||
|         { |         { | ||||||
|  |           title: scenario.title, | ||||||
|           key: scenario.title.parameterize.underscore, |           key: scenario.title.parameterize.underscore, | ||||||
|           description: scenario.description |           description: scenario.description | ||||||
|         } |         } | ||||||
|   | |||||||
							
								
								
									
										100
									
								
								enterprise/app/models/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										100
									
								
								enterprise/app/models/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -38,7 +38,7 @@ class Captain::Scenario < ApplicationRecord | |||||||
|  |  | ||||||
|   scope :enabled, -> { where(enabled: true) } |   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 |   before_save :resolve_tool_references | ||||||
|  |  | ||||||
| @@ -46,7 +46,10 @@ class Captain::Scenario < ApplicationRecord | |||||||
|     { |     { | ||||||
|       title: title, |       title: title, | ||||||
|       instructions: resolved_instructions, |       instructions: resolved_instructions, | ||||||
|       tools: resolved_tools |       tools: resolved_tools, | ||||||
|  |       assistant_name: assistant.name.downcase.gsub(/\s+/, '_'), | ||||||
|  |       response_guidelines: response_guidelines || [], | ||||||
|  |       guardrails: guardrails || [] | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
|  |  | ||||||
| @@ -57,24 +60,34 @@ class Captain::Scenario < ApplicationRecord | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def agent_tools |   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 |   end | ||||||
|  |  | ||||||
|   def resolved_instructions |   def resolved_instructions | ||||||
|     instruction.gsub(TOOL_REFERENCE_REGEX) do |match| |     instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool') | ||||||
|       "#{match} tool " |  | ||||||
|     end |  | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def resolved_tools |   def resolved_tools | ||||||
|     return [] if tools.blank? |     return [] if tools.blank? | ||||||
|  |  | ||||||
|     available_tools = self.class.available_agent_tools |     available_tools = assistant.available_agent_tools | ||||||
|     tools.filter_map do |tool_id| |     tools.filter_map do |tool_id| | ||||||
|       available_tools.find { |tool| tool[:id] == tool_id } |       available_tools.find { |tool| tool[:id] == tool_id } | ||||||
|     end |     end | ||||||
|   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. |   # Validates that all tool references in the instruction are valid. | ||||||
|   # Parses the instruction for tool references and checks if they exist |   # Parses the instruction for tool references and checks if they exist | ||||||
|   # in the available tools configuration. |   # in the available tools configuration. | ||||||
| @@ -95,8 +108,8 @@ class Captain::Scenario < ApplicationRecord | |||||||
|     tool_ids = extract_tool_ids_from_text(instruction) |     tool_ids = extract_tool_ids_from_text(instruction) | ||||||
|     return if tool_ids.empty? |     return if tool_ids.empty? | ||||||
|  |  | ||||||
|     available_tool_ids = self.class.available_tool_ids |     all_available_tool_ids = assistant.available_tool_ids | ||||||
|     invalid_tools = tool_ids - available_tool_ids |     invalid_tools = tool_ids - all_available_tool_ids | ||||||
|  |  | ||||||
|     return unless invalid_tools.any? |     return unless invalid_tools.any? | ||||||
|  |  | ||||||
|   | |||||||
| @@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers | |||||||
|   TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} |   TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)} | ||||||
|  |  | ||||||
|   class_methods do |   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. |     # Only includes tools that have corresponding class files and can be resolved. | ||||||
|     # |     # | ||||||
|     # @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon |     # @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon | ||||||
|     def available_agent_tools |     def built_in_agent_tools | ||||||
|       @available_agent_tools ||= load_agent_tools |       @built_in_agent_tools ||= load_agent_tools | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     # Resolves a tool class from a tool ID. |     # Resolves a tool class from a tool ID. | ||||||
| @@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers | |||||||
|       class_name.safe_constantize |       class_name.safe_constantize | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     # Returns an array of all available tool IDs. |     # Returns an array of all built-in tool IDs. | ||||||
|     # Convenience method that extracts just the IDs from available_agent_tools. |     # Convenience method that extracts just the IDs from built_in_agent_tools. | ||||||
|     # |     # | ||||||
|     # @return [Array<String>] Array of available tool IDs |     # @return [Array<String>] Array of built-in tool IDs | ||||||
|     def available_tool_ids |     def built_in_tool_ids | ||||||
|       @available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] } |       @built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] } | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     private |     private | ||||||
|   | |||||||
							
								
								
									
										84
									
								
								enterprise/app/models/concerns/safe_endpoint_validatable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								enterprise/app/models/concerns/safe_endpoint_validatable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										91
									
								
								enterprise/app/models/concerns/toolable.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								enterprise/app/models/concerns/toolable.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -10,6 +10,7 @@ module Enterprise::Concerns::Account | |||||||
|     has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant' |     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_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' | ||||||
|     has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' |     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 :copilot_threads, dependent: :destroy_async | ||||||
|     has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' |     has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice' | ||||||
|   | |||||||
							
								
								
									
										21
									
								
								enterprise/app/policies/captain/custom_tool_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								enterprise/app/policies/captain/custom_tool_policy.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -74,7 +74,12 @@ class Captain::Assistant::AgentRunnerService | |||||||
|   # Response formatting methods |   # Response formatting methods | ||||||
|   def process_agent_result(result) |   def process_agent_result(result) | ||||||
|     Rails.logger.info "[Captain V2] Agent result: #{result.inspect}" |     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 |   end | ||||||
|  |  | ||||||
|   def format_response(output) |   def format_response(output) | ||||||
|   | |||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||||
| @@ -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 | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||||
| @@ -0,0 +1 @@ | |||||||
|  | json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool | ||||||
| @@ -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 | ||||||
| @@ -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. | 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 | # 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 }} | {{ 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 | # Current Context | ||||||
|  |  | ||||||
| Here's the metadata we have about the current conversation and the contact associated with it: | 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 -%} | {% if contact -%} | ||||||
| {% render 'contact' %} | {% render 'contact' %} | ||||||
| {% endif -%} | {% endif -%} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
| {% if response_guidelines.size > 0 -%} | {% if response_guidelines.size > 0 -%} | ||||||
| # Response Guidelines | # Response Guidelines | ||||||
| Your responses should follow these guidelines: | Your responses should follow these guidelines: | ||||||
| {% for guideline in response_guidelines -%} | {% for guideline in response_guidelines -%} | ||||||
| - {{ guideline }} | - {{ guideline }} | ||||||
|  | - Be conversational but professional | ||||||
|  | - Provide actionable information | ||||||
|  | - Include relevant details from tool responses | ||||||
| {% endfor %} | {% endfor %} | ||||||
| {% endif -%} | {% endif -%} | ||||||
|  |  | ||||||
| @@ -45,30 +50,26 @@ First, understand what the user is asking: | |||||||
| - **Complexity**: Can you handle it or does it need specialized expertise? | - **Complexity**: Can you handle it or does it need specialized expertise? | ||||||
|  |  | ||||||
| ## 2. Check for Specialized Scenarios First | ## 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 -%} | {% for scenario in scenarios -%} | ||||||
| ### handoff_to_{{ scenario.key }} | - {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent. | ||||||
| {{ scenario.description }} | {% endfor %} | ||||||
| {% endfor -%} | If unclear, ask clarifying questions to determine if a scenario applies: | ||||||
|  |  | ||||||
| ## 3. Handle the Request | ## 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 | ### For Questions and Information Requests | ||||||
| 1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information | 1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information | ||||||
| 2. **If not found in FAQs**: Provide your best answer based on available context | 2. **If not found in FAQs**: Try to ask clarifying questions to gather more information | ||||||
| 3. **If unable to answer**: Use `handoff` tool to transfer to a human expert | 3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert | ||||||
|  |  | ||||||
| ### For Complex or Unclear Requests | ### For Complex or Unclear Requests | ||||||
| 1. **Ask clarifying questions**: Gather more information if needed | 1. **Ask clarifying questions**: Gather more information if needed | ||||||
| 2. **Break down complex tasks**: Handle step by step or hand off if too complex | 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 | 3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities | ||||||
|  |  | ||||||
| ## Response Best Practices |  | ||||||
| - Be conversational but professional |  | ||||||
| - Provide actionable information |  | ||||||
| - Include relevant details from tool responses |  | ||||||
|  |  | ||||||
| # Human Handoff Protocol | # Human Handoff Protocol | ||||||
| Transfer to a human agent when: | 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 | - The issue requires specialized knowledge or permissions you don't have | ||||||
| - Multiple attempts to help have been unsuccessful | - 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. | ||||||
|   | |||||||
| @@ -1,20 +1,44 @@ | |||||||
| # System context | # System context | ||||||
| You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. | 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. | ||||||
| The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally. |  | ||||||
|  |  | ||||||
| # Your Role | # 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 }} | {{ 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 -%} | {% if conversation -%} | ||||||
| {% render 'conversation' %} | {% render 'conversation' %} | ||||||
|  | {% endif -%} | ||||||
|  |  | ||||||
| {% if contact -%} | {% if contact -%} | ||||||
| {% render 'contact' %} | {% render 'contact' %} | ||||||
| {% endif -%} | {% endif -%} | ||||||
| {% 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 -%} | {% if tools.size > 0 -%} | ||||||
| # Available Tools | # Available Tools | ||||||
| You have access to these tools: | You have access to these tools: | ||||||
|   | |||||||
							
								
								
									
										105
									
								
								enterprise/lib/captain/tools/http_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								enterprise/lib/captain/tools/http_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -6,6 +6,7 @@ module Limits | |||||||
|   GREETING_MESSAGE_MAX_LENGTH = 10_000 |   GREETING_MESSAGE_MAX_LENGTH = 10_000 | ||||||
|   CATEGORIES_PER_PAGE = 1000 |   CATEGORIES_PER_PAGE = 1000 | ||||||
|   AUTO_ASSIGNMENT_BULK_LIMIT = 100 |   AUTO_ASSIGNMENT_BULK_LIMIT = 100 | ||||||
|  |   MAX_CUSTOM_FILTERS_PER_USER = 1000 | ||||||
|  |  | ||||||
|   def self.conversation_message_per_minute_limit |   def self.conversation_message_per_minute_limit | ||||||
|     ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i |     ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i | ||||||
|   | |||||||
| @@ -118,7 +118,7 @@ class CaptainChatSession | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def show_available_tools |   def show_available_tools | ||||||
|     available_tools = Captain::Assistant.available_tool_ids |     available_tools = @assistant.available_tool_ids | ||||||
|     if available_tools.any? |     if available_tools.any? | ||||||
|       puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}" |       puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}" | ||||||
|     else |     else | ||||||
|   | |||||||
| @@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do | |||||||
|         expect(json_response['name']).to eq 'vip-customers' |         expect(json_response['name']).to eq 'vip-customers' | ||||||
|       end |       end | ||||||
|  |  | ||||||
|       it 'gives the error for 51st record' do |       it 'gives the error for 1001st record' do | ||||||
|         CustomFilter.delete_all |         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) |           create(:custom_filter, user: user, account: account) | ||||||
|         end |         end | ||||||
|  |  | ||||||
| @@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do | |||||||
|         expect(response).to have_http_status(:unprocessable_entity) |         expect(response).to have_http_status(:unprocessable_entity) | ||||||
|         json_response = response.parsed_body |         json_response = response.parsed_body | ||||||
|         expect(json_response['message']).to include( |         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 | ||||||
|     end |     end | ||||||
|   | |||||||
| @@ -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 | ||||||
							
								
								
									
										241
									
								
								spec/enterprise/lib/captain/tools/http_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										241
									
								
								spec/enterprise/lib/captain/tools/http_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
							
								
								
									
										388
									
								
								spec/enterprise/models/captain/custom_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										388
									
								
								spec/enterprise/models/captain/custom_tool_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
| @@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do | |||||||
|  |  | ||||||
|     before do |     before do | ||||||
|       # Mock available tools |       # Mock available tools | ||||||
|       allow(described_class).to receive(:available_tool_ids).and_return(%w[ |       allow(described_class).to receive(:built_in_tool_ids).and_return(%w[ | ||||||
|                                                                           add_contact_note add_private_note update_priority |                                                                          add_contact_note add_private_note update_priority | ||||||
|                                                                         ]) |                                                                        ]) | ||||||
|     end |     end | ||||||
|  |  | ||||||
|     describe 'validate_instruction_tools' do |     describe 'validate_instruction_tools' do | ||||||
| @@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do | |||||||
|         expect(scenario).not_to be_valid |         expect(scenario).not_to be_valid | ||||||
|         expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) |         expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/) | ||||||
|       end |       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 |     end | ||||||
|  |  | ||||||
|     describe 'resolve_tool_references' do |     describe 'resolve_tool_references' do | ||||||
| @@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do | |||||||
|     end |     end | ||||||
|   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 |   describe 'factory' do | ||||||
|     it 'creates a valid scenario with associations' do |     it 'creates a valid scenario with associations' do | ||||||
|       account = create(:account) |       account = create(:account) | ||||||
|   | |||||||
| @@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do | |||||||
|     end |     end | ||||||
|   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 |   describe '.resolve_tool_class' do | ||||||
|     it 'resolves valid tool classes' do |     it 'resolves valid tool classes' do | ||||||
|       # Mock the constantize to return a class |       # Mock the constantize to return a class | ||||||
| @@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do | |||||||
|     end |     end | ||||||
|   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 |   describe '#extract_tool_ids_from_text' do | ||||||
|     it 'extracts 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)' |       text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)' | ||||||
|   | |||||||
| @@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do | |||||||
|   let(:mock_runner) { instance_double(Agents::Runner) } |   let(:mock_runner) { instance_double(Agents::Runner) } | ||||||
|   let(:mock_agent) { instance_double(Agents::Agent) } |   let(:mock_agent) { instance_double(Agents::Agent) } | ||||||
|   let(:mock_scenario_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 |   let(:message_history) do | ||||||
|     [ |     [ | ||||||
| @@ -99,7 +99,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do | |||||||
|     it 'processes and formats agent result' do |     it 'processes and formats agent result' do | ||||||
|       result = service.generate_response(message_history: message_history) |       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 |     end | ||||||
|  |  | ||||||
|     context 'when no scenarios are enabled' do |     context 'when no scenarios are enabled' do | ||||||
| @@ -118,14 +118,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do | |||||||
|     end |     end | ||||||
|  |  | ||||||
|     context 'when agent result is a string' do |     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 |       it 'formats string response correctly' do | ||||||
|         result = service.generate_response(message_history: message_history) |         result = service.generate_response(message_history: message_history) | ||||||
|  |  | ||||||
|         expect(result).to eq({ |         expect(result).to eq({ | ||||||
|                                'response' => 'Simple string response', |                                'response' => 'Simple string response', | ||||||
|                                'reasoning' => 'Processed by agent' |                                'reasoning' => 'Processed by agent', | ||||||
|  |                                'agent_name' => nil | ||||||
|                              }) |                              }) | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   | |||||||
							
								
								
									
										51
									
								
								spec/factories/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										51
									
								
								spec/factories/captain/custom_tool.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth