Merge branch 'develop' into chore/partipitant-conversations

This commit is contained in:
Muhsin Keloth
2025-10-07 10:36:59 +05:30
committed by GitHub
53 changed files with 2837 additions and 136 deletions

View 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();

View File

@@ -10,6 +10,10 @@ const props = defineProps({
type: String,
required: true,
},
translationKey: {
type: String,
required: true,
},
entity: {
type: Object,
required: true,
@@ -25,7 +29,9 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const deleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
return props.translationKey || props.type.toUpperCase();
});
const deleteEntity = async payload => {
if (!payload) return;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -238,6 +238,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
to: accountScopedRoute('captain_responses_index'),
},
{
name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'),
to: accountScopedRoute('captain_tools_index'),
},
],
},
{

View File

@@ -49,6 +49,5 @@ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER,
FEATURE_FLAGS.CAPTAIN_V2,
FEATURE_FLAGS.SAML,
];

View File

@@ -750,6 +750,115 @@
}
}
},
"CUSTOM_TOOLS": {
"HEADER": "Tools",
"ADD_NEW": "Create a new tool",
"EMPTY_STATE": {
"TITLE": "No custom tools available",
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
"FEATURE_SPOTLIGHT": {
"TITLE": "Custom Tools",
"NOTE": "Custom tools allow your assistant to interact with external APIs and services. Create tools to fetch data, perform actions, or integrate with your existing systems to enhance your assistant's capabilities."
}
},
"FORM_DESCRIPTION": "Configure your custom tool to connect with external APIs",
"OPTIONS": {
"EDIT_TOOL": "Edit tool",
"DELETE_TOOL": "Delete tool"
},
"CREATE": {
"TITLE": "Create Custom Tool",
"SUCCESS_MESSAGE": "Custom tool created successfully",
"ERROR_MESSAGE": "Failed to create custom tool"
},
"EDIT": {
"TITLE": "Edit Custom Tool",
"SUCCESS_MESSAGE": "Custom tool updated successfully",
"ERROR_MESSAGE": "Failed to update custom tool"
},
"DELETE": {
"TITLE": "Delete Custom Tool",
"DESCRIPTION": "Are you sure you want to delete this custom tool? This action cannot be undone.",
"CONFIRM": "Yes, delete",
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool"
},
"FORM": {
"TITLE": {
"LABEL": "Tool Name",
"PLACEHOLDER": "Order Lookup",
"ERROR": "Tool name is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Looks up order details by order ID"
},
"HTTP_METHOD": {
"LABEL": "Method"
},
"ENDPOINT_URL": {
"LABEL": "Endpoint URL",
"PLACEHOLDER": "https://api.example.com/orders/{'{{'} order_id {'}}'}",
"ERROR": "Valid URL is required"
},
"AUTH_TYPE": {
"LABEL": "Authentication Type"
},
"AUTH_TYPES": {
"NONE": "None",
"BEARER": "Bearer Token",
"BASIC": "Basic Auth",
"API_KEY": "API Key"
},
"AUTH_CONFIG": {
"BEARER_TOKEN": "Bearer Token",
"BEARER_TOKEN_PLACEHOLDER": "Enter your bearer token",
"USERNAME": "Username",
"USERNAME_PLACEHOLDER": "Enter username",
"PASSWORD": "Password",
"PASSWORD_PLACEHOLDER": "Enter password",
"API_KEY": "Header Name",
"API_KEY_PLACEHOLDER": "X-API-Key",
"API_VALUE": "Header Value",
"API_VALUE_PLACEHOLDER": "Enter API key value"
},
"PARAMETERS": {
"LABEL": "Parameters",
"HELP_TEXT": "Define the parameters that will be extracted from user queries"
},
"ADD_PARAMETER": "Add Parameter",
"PARAM_NAME": {
"PLACEHOLDER": "Parameter name (e.g., order_id)"
},
"PARAM_TYPE": {
"PLACEHOLDER": "Type"
},
"PARAM_TYPES": {
"STRING": "String",
"NUMBER": "Number",
"BOOLEAN": "Boolean",
"ARRAY": "Array",
"OBJECT": "Object"
},
"PARAM_DESCRIPTION": {
"PLACEHOLDER": "Description of the parameter"
},
"PARAM_REQUIRED": {
"LABEL": "Required"
},
"REQUEST_TEMPLATE": {
"LABEL": "Request Body Template (Optional)",
"PLACEHOLDER": "{'{'}\n \"order_id\": \"{'{{'} order_id {'}}'}\"\n{'}'}"
},
"RESPONSE_TEMPLATE": {
"LABEL": "Response Template (Optional)",
"PLACEHOLDER": "Order {'{{'} order_id {'}}'} status: {'{{'} status {'}}'}"
},
"ERRORS": {
"PARAM_NAME_REQUIRED": "Parameter name is required"
}
}
},
"RESPONSES": {
"HEADER": "FAQs",
"ADD_NEW": "Create new FAQ",

View File

@@ -304,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",

View File

@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
import CustomToolsIndex from './tools/Index.vue';
export const routes = [
{
@@ -124,4 +125,17 @@ export const routes = [
],
},
},
{
path: frontendURL('accounts/:accountId/captain/tools'),
component: CustomToolsIndex,
name: 'captain_tools_index',
meta: {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
];

View File

@@ -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>

View 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);
}
},
}),
});

View File

@@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'captainTool',
name: 'Tools',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {

View File

@@ -57,6 +57,7 @@ import copilotThreads from './captain/copilotThreads';
import copilotMessages from './captain/copilotMessages';
import captainScenarios from './captain/scenarios';
import captainTools from './captain/tools';
import captainCustomTools from './captain/customTools';
const plugins = [];
@@ -119,6 +120,7 @@ export default createStore({
copilotMessages,
captainScenarios,
captainTools,
captainCustomTools,
},
plugins,
});

View File

@@ -17,7 +17,6 @@
# index_custom_filters_on_user_id (user_id)
#
class CustomFilter < ApplicationRecord
MAX_FILTER_PER_USER = 50
belongs_to :user
belongs_to :account
@@ -25,7 +24,7 @@ class CustomFilter < ApplicationRecord
validate :validate_number_of_filters
def validate_number_of_filters
return true if account.custom_filters.where(user_id: user_id).size < MAX_FILTER_PER_USER
return true if account.custom_filters.where(user_id: user_id).size < Limits::MAX_CUSTOM_FILTERS_PER_USER
errors.add :account_id, I18n.t('errors.custom_filters.number_of_records')
end

View File

@@ -15,6 +15,7 @@ Rails.application.config.after_initialize do
config.openai_api_base = api_base
end
config.default_model = model
config.max_turns = 30
config.debug = false
end
end

View File

@@ -100,7 +100,7 @@ en:
validations:
name: should not start or end with symbols, and it should not have < > / \ @ characters.
custom_filters:
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 50.
number_of_records: Limit reached. The maximum number of allowed custom filters for a user per account is 1000.
invalid_attribute: Invalid attribute key - [%{key}]. The key should be one of [%{allowed_keys}] or a custom attribute defined in the account.
invalid_operator: Invalid operator. The allowed operators for %{attribute_name} are [%{allowed_keys}].
invalid_query_operator: Query operator must be either "AND" or "OR".
@@ -336,6 +336,8 @@ en:
processing_pages: 'Processing pages %{start}-%{end} (iteration %{iteration})'
chunk_generated: 'Chunk generated %{chunk_faqs} FAQs. Total so far: %{total_faqs}'
page_processing_error: 'Error processing pages %{start}-%{end}: %{error}'
custom_tool:
slug_generation_failed: 'Unable to generate unique slug after 5 attempts'
public_portal:
search:
search_placeholder: Search for article by title or body...

View File

@@ -67,6 +67,7 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :custom_tools
resources :documents, only: [:index, :show, :create, :destroy]
end
resource :saml_settings, only: [:show, :create, :update, :destroy]

View 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

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
ActiveRecord::Schema[7.1].define(version: 2025_10_03_091242) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -323,6 +323,25 @@ ActiveRecord::Schema[7.1].define(version: 2025_09_17_012759) do
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
end
create_table "captain_custom_tools", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "slug", null: false
t.string "title", null: false
t.text "description"
t.string "http_method", default: "GET", null: false
t.text "endpoint_url", null: false
t.text "request_template"
t.text "response_template"
t.string "auth_type", default: "none"
t.jsonb "auth_config", default: {}
t.jsonb "param_schema", default: []
t.boolean "enabled", default: true, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "slug"], name: "index_captain_custom_tools_on_account_id_and_slug", unique: true
t.index ["account_id"], name: "index_captain_custom_tools_on_account_id"
end
create_table "captain_documents", force: :cascade do |t|
t.string "name"
t.string "external_link", null: false

View File

@@ -33,7 +33,8 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def tools
@tools = Captain::Assistant.available_agent_tools
assistant = Captain::Assistant.new(account: Current.account)
@tools = assistant.available_agent_tools
end
private

View File

@@ -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

View File

@@ -49,10 +49,15 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
{
message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
# Include agent_name if present in additional_attributes
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
message_hash
end
end
@@ -79,25 +84,31 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def create_handoff_message
create_outgoing_message(@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'))
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
)
end
def create_messages
validate_message_content!(@response['response'])
create_outgoing_message(@response['response'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
def create_outgoing_message(message_content)
def create_outgoing_message(message_content, agent_name: nil)
additional_attrs = {}
additional_attrs[:agent_name] = agent_name if agent_name.present?
@conversation.messages.create!(
message_type: :outgoing,
account_id: account.id,
inbox_id: inbox.id,
sender: @assistant,
content: message_content
content: message_content,
additional_attributes: additional_attrs
)
end

View File

@@ -50,6 +50,19 @@ class Captain::Assistant < ApplicationRecord
name
end
def available_agent_tools
tools = self.class.built_in_agent_tools.dup
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
tools.concat(custom_tools)
tools
end
def available_tool_ids
available_agent_tools.pluck(:id)
end
def push_event_data
{
id: id,
@@ -92,6 +105,7 @@ class Captain::Assistant < ApplicationRecord
product_name: config['product_name'] || 'this product',
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.title.parameterize.underscore,
description: scenario.description
}

View 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

View File

@@ -38,7 +38,7 @@ class Captain::Scenario < ApplicationRecord
scope :enabled, -> { where(enabled: true) }
delegate :temperature, :feature_faq, :feature_memory, :product_name, to: :assistant
delegate :temperature, :feature_faq, :feature_memory, :product_name, :response_guidelines, :guardrails, to: :assistant
before_save :resolve_tool_references
@@ -46,7 +46,10 @@ class Captain::Scenario < ApplicationRecord
{
title: title,
instructions: resolved_instructions,
tools: resolved_tools
tools: resolved_tools,
assistant_name: assistant.name.downcase.gsub(/\s+/, '_'),
response_guidelines: response_guidelines || [],
guardrails: guardrails || []
}
end
@@ -57,24 +60,34 @@ class Captain::Scenario < ApplicationRecord
end
def agent_tools
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
resolved_tools.map { |tool| resolve_tool_instance(tool) }
end
def resolved_instructions
instruction.gsub(TOOL_REFERENCE_REGEX) do |match|
"#{match} tool "
end
instruction.gsub(TOOL_REFERENCE_REGEX, '`\1` tool')
end
def resolved_tools
return [] if tools.blank?
available_tools = self.class.available_agent_tools
available_tools = assistant.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
def resolve_tool_instance(tool_metadata)
tool_id = tool_metadata[:id]
if tool_metadata[:custom]
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
custom_tool&.tool(assistant)
else
tool_class = self.class.resolve_tool_class(tool_id)
tool_class&.new(assistant)
end
end
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
@@ -95,8 +108,8 @@ class Captain::Scenario < ApplicationRecord
tool_ids = extract_tool_ids_from_text(instruction)
return if tool_ids.empty?
available_tool_ids = self.class.available_tool_ids
invalid_tools = tool_ids - available_tool_ids
all_available_tool_ids = assistant.available_tool_ids
invalid_tools = tool_ids - all_available_tool_ids
return unless invalid_tools.any?

View File

@@ -8,12 +8,12 @@ module Concerns::CaptainToolsHelpers
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
class_methods do
# Returns all available agent tools with their metadata.
# Returns all built-in agent tools with their metadata.
# Only includes tools that have corresponding class files and can be resolved.
#
# @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon
def available_agent_tools
@available_agent_tools ||= load_agent_tools
def built_in_agent_tools
@built_in_agent_tools ||= load_agent_tools
end
# Resolves a tool class from a tool ID.
@@ -26,12 +26,12 @@ module Concerns::CaptainToolsHelpers
class_name.safe_constantize
end
# Returns an array of all available tool IDs.
# Convenience method that extracts just the IDs from available_agent_tools.
# Returns an array of all built-in tool IDs.
# Convenience method that extracts just the IDs from built_in_agent_tools.
#
# @return [Array<String>] Array of available tool IDs
def available_tool_ids
@available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] }
# @return [Array<String>] Array of built-in tool IDs
def built_in_tool_ids
@built_in_tool_ids ||= built_in_agent_tools.map { |tool| tool[:id] }
end
private

View 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

View 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

View File

@@ -10,6 +10,7 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :copilot_threads, dependent: :destroy_async
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'

View 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

View File

@@ -74,7 +74,12 @@ class Captain::Assistant::AgentRunnerService
# Response formatting methods
def process_agent_result(result)
Rails.logger.info "[Captain V2] Agent result: #{result.inspect}"
format_response(result.output)
response = format_response(result.output)
# Extract agent name from context
response['agent_name'] = result.context&.dig(:current_agent)
response
end
def format_response(output)

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -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

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool

View File

@@ -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

View File

@@ -2,12 +2,13 @@
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
# Your Identity
You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide accurate information, assist with tasks, and ensure users get the help they need.
You are {{name}}, a helpful and knowledgeable assistant. Your role is to primarily act as a orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer get the help they need.
{{ description }}
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the faq_lookup tool for this.
Don't digress away from your instructions, and use all the available tools at your disposal for solving customer issues. If you are to state something factual about {{product_name}} ensure you source that information from the FAQs only. Use the `captain--tools--faq_lookup` tool for this.
{% if conversation || contact -%}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -19,12 +20,16 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%}
- {{ guideline }}
- Be conversational but professional
- Provide actionable information
- Include relevant details from tool responses
{% endfor %}
{% endif -%}
@@ -45,30 +50,26 @@ First, understand what the user is asking:
- **Complexity**: Can you handle it or does it need specialized expertise?
## 2. Check for Specialized Scenarios First
Before using any tools, check if the request matches any of these scenarios. If unclear, ask clarifying questions to determine if a scenario applies:
Before using any tools, check if the request matches any of these scenarios. If it seems like a particular scenario matches, use the specific handoff tool to transfer the conversation to the specific agent. The following are the scenario agents that are available to you.
{% for scenario in scenarios -%}
### handoff_to_{{ scenario.key }}
{{ scenario.description }}
{% endfor -%}
- {{ scenario.title }}: {{ scenario.description }}, use the `handoff_to_{{ scenario.key }}` tool to transfer the conversation to the {{ scenario.title }} agent.
{% endfor %}
If unclear, ask clarifying questions to determine if a scenario applies:
## 3. Handle the Request
If no specialized scenario clearly matches, handle it yourself:
If no specialized scenario clearly matches, handle it yourself in the following way
### For Questions and Information Requests
1. **First, check existing knowledge**: Use `faq_lookup` tool to search for relevant information
2. **If not found in FAQs**: Provide your best answer based on available context
3. **If unable to answer**: Use `handoff` tool to transfer to a human expert
1. **First, check existing knowledge**: Use `captain--tools--faq_lookup` tool to search for relevant information
2. **If not found in FAQs**: Try to ask clarifying questions to gather more information
3. **If unable to answer**: Use `captain--tools--handoff` tool to transfer to a human expert
### For Complex or Unclear Requests
1. **Ask clarifying questions**: Gather more information if needed
2. **Break down complex tasks**: Handle step by step or hand off if too complex
3. **Escalate when necessary**: Use `handoff` tool for issues beyond your capabilities
## Response Best Practices
- Be conversational but professional
- Provide actionable information
- Include relevant details from tool responses
3. **Escalate when necessary**: Use `captain--tools--handoff` tool for issues beyond your capabilities
# Human Handoff Protocol
Transfer to a human agent when:
@@ -77,4 +78,4 @@ Transfer to a human agent when:
- The issue requires specialized knowledge or permissions you don't have
- Multiple attempts to help have been unsuccessful
When using the `handoff` tool, provide a clear reason that helps the human agent understand the context.
When using the `captain--tools--handoff` tool, provide a clear reason that helps the human agent understand the context.

View File

@@ -1,20 +1,44 @@
# System context
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task.
The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
# Your Role
You are a specialized agent called {{ title }}, your task is to handle the following scenario:
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
{{ instructions }}
If you believe the user's request is not within the scope of your role, you can assign this conversation back to the orchestrator agent using the `handoff_to_{{ assistant_name }}` tool
{% if conversation || contact %}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
{% if conversation -%}
{% render 'conversation' %}
{% endif -%}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
{% for guideline in response_guidelines -%}
- {{ guideline }}
{% endfor %}
{% endif -%}
{% if guardrails.size > 0 -%}
# Guardrails
Always respect these boundaries:
{% for guardrail in guardrails -%}
- {{ guardrail }}
{% endfor %}
{% endif -%}
{% if tools.size > 0 -%}
# Available Tools
You have access to these tools:

View 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

View File

@@ -6,6 +6,7 @@ module Limits
GREETING_MESSAGE_MAX_LENGTH = 10_000
CATEGORIES_PER_PAGE = 1000
AUTO_ASSIGNMENT_BULK_LIMIT = 100
MAX_CUSTOM_FILTERS_PER_USER = 1000
def self.conversation_message_per_minute_limit
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i

View File

@@ -118,7 +118,7 @@ class CaptainChatSession
end
def show_available_tools
available_tools = Captain::Assistant.available_tool_ids
available_tools = @assistant.available_tool_ids
if available_tools.any?
puts "🔧 Available Tools (#{available_tools.count}): #{available_tools.join(', ')}"
else

View File

@@ -93,9 +93,9 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(json_response['name']).to eq 'vip-customers'
end
it 'gives the error for 51st record' do
it 'gives the error for 1001st record' do
CustomFilter.delete_all
CustomFilter::MAX_FILTER_PER_USER.times do
Limits::MAX_CUSTOM_FILTERS_PER_USER.times do
create(:custom_filter, user: user, account: account)
end
@@ -107,7 +107,7 @@ RSpec.describe 'Custom Filters API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to include(
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 50.'
'Account Limit reached. The maximum number of allowed custom filters for a user per account is 1000.'
)
end
end

View File

@@ -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

View 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

View 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

View File

@@ -48,9 +48,9 @@ RSpec.describe Captain::Scenario, type: :model do
before do
# Mock available tools
allow(described_class).to receive(:available_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
end
describe 'validate_instruction_tools' do
@@ -102,6 +102,49 @@ RSpec.describe Captain::Scenario, type: :model do
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
it 'is valid with custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).to be_valid
end
it 'is invalid with custom tool from different account' do
other_account = create(:account)
create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is invalid with disabled custom tool' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is valid with mixed static and custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
expect(scenario).to be_valid
end
end
describe 'resolve_tool_references' do
@@ -146,6 +189,140 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
describe 'custom tool integration' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
allow(described_class).to receive(:built_in_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note',
description: 'Add a note' }
])
end
describe '#resolved_tools' do
it 'includes custom tool metadata' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
title: 'Fetch Order', description: 'Gets order details')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(1)
expect(resolved.first[:id]).to eq('custom_fetch-order')
expect(resolved.first[:title]).to eq('Fetch Order')
expect(resolved.first[:description]).to eq('Gets order details')
end
it 'includes both static and custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(2)
expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
end
it 'excludes disabled custom tools' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
resolved = scenario.send(:resolved_tools)
expect(resolved).to be_empty
end
end
describe '#resolve_tool_instance' do
it 'returns HttpTool instance for custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'returns nil for disabled custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_nil
end
it 'returns static tool instance for non-custom tools' do
scenario = create(:captain_scenario, assistant: assistant, account: account)
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tool_metadata = { id: 'add_contact_note' }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).not_to be_nil
expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
end
end
describe '#agent_tools' do
it 'returns array of tool instances including custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(1)
expect(tools.first).to be_a(Captain::Tools::HttpTool)
end
it 'excludes disabled custom tools from execution' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
tools = scenario.send(:agent_tools)
expect(tools).to be_empty
end
it 'returns mixed static and custom tool instances' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(2)
expect(tools.last).to be_a(Captain::Tools::HttpTool)
end
end
end
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)

View File

@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
describe '.available_agent_tools' do
before do
# Mock the YAML file loading
allow(YAML).to receive(:load_file).and_return([
{
'id' => 'add_contact_note',
'title' => 'Add Contact Note',
'description' => 'Add a note to a contact',
'icon' => 'note-add'
},
{
'id' => 'invalid_tool',
'title' => 'Invalid Tool',
'description' => 'This tool does not exist',
'icon' => 'invalid'
}
])
# Mock class resolution - only add_contact_note exists
allow(test_class).to receive(:resolve_tool_class) do |tool_id|
case tool_id
when 'add_contact_note'
Captain::Tools::AddContactNoteTool
end
end
end
it 'returns only resolvable tools' do
tools = test_class.available_agent_tools
expect(tools.length).to eq(1)
expect(tools.first).to eq({
id: 'add_contact_note',
title: 'Add Contact Note',
description: 'Add a note to a contact',
icon: 'note-add'
})
end
it 'logs warnings for unresolvable tools' do
expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
test_class.available_agent_tools
end
it 'memoizes the result' do
expect(YAML).to receive(:load_file).once.and_return([])
2.times { test_class.available_agent_tools }
end
end
describe '.resolve_tool_class' do
it 'resolves valid tool classes' do
# Mock the constantize to return a class
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
end
end
describe '.available_tool_ids' do
before do
allow(test_class).to receive(:available_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note', description: '...',
icon: 'note' },
{ id: 'update_priority', title: 'Update Priority', description: '...',
icon: 'priority' }
])
end
it 'returns array of tool IDs' do
ids = test_class.available_tool_ids
expect(ids).to eq(%w[add_contact_note update_priority])
end
it 'memoizes the result' do
expect(test_class).to receive(:available_agent_tools).once.and_return([])
2.times { test_class.available_tool_ids }
end
end
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'

View File

@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
let(:mock_runner) { instance_double(Agents::Runner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
@@ -99,7 +99,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({ 'response' => 'Test response' })
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil })
end
context 'when no scenarios are enabled' do
@@ -118,14 +118,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'when agent result is a string' do
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response') }
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
'reasoning' => 'Processed by agent'
'reasoning' => 'Processed by agent',
'agent_name' => nil
})
end
end

View 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