Compare commits

...
Author SHA1 Message Date
Shivam Mishra 3d225afdd2 fix: formatting 2025-10-06 10:02:49 +05:30
Shivam Mishra a20c3c44c8 feat: better prompts 2025-10-06 10:01:01 +05:30
Shivam Mishra 20c200e064 feat: better scenario prompt 2025-10-06 09:44:51 +05:30
Shivam Mishra 172ec60854 feat: allow scenario to access guardrails and guidelines 2025-10-06 09:44:40 +05:30
Shivam Mishra eb86629ece fix: auth config 2025-10-06 09:29:59 +05:30
Shivam Mishra f01460d16c fix: scrolling UI 2025-10-06 09:28:21 +05:30
Shivam Mishra a49a36785b fix: form size 2025-10-06 09:16:12 +05:30
Shivam Mishra 1ed95629e5 fix: delete binding 2025-10-06 09:13:12 +05:30
Shivam Mishra 2d705f66a1 feat: remove feature flag 2025-10-06 08:33:41 +05:30
Shivam MishraandGitHub 2c3f5e966f Merge branch 'feat/captain-product-flows' into feat/custom-tools-frontend 2025-10-06 08:28:45 +05:30
Shivam Mishra 4c6d3376ef test: handle failing ipaddr lookup 2025-10-06 08:27:19 +05:30
Shivam Mishra 3e955b450b fix: naive url validation broke template syntax 2025-10-06 08:21:52 +05:30
Pranav 910a2de087 remove undefined methods from spec 2025-10-05 16:01:08 -07:00
Pranav 99884235a4 Update the associations to have a cleaner interface 2025-10-05 12:13:23 -07:00
Shivam Mishra b02ac1b794 fix: tools 2025-10-03 20:42:22 +05:30
Shivam Mishra 21ddbde89b fix: tool name 2025-10-03 20:36:53 +05:30
Shivam Mishra cb7a16b969 fix: validation 2025-10-03 20:36:29 +05:30
Shivam Mishra 15a1a8cd5f fix: tool 2025-10-03 20:35:40 +05:30
Shivam Mishra 30e0d6b610 feat: integrate delete 2025-10-03 20:00:40 +05:30
Shivam Mishra c2b801f5d8 feat: remove params from card 2025-10-03 20:00:29 +05:30
Shivam Mishra b1328b8574 feat: allow custom i18 key 2025-10-03 20:00:03 +05:30
Shivam Mishra 3860fd01ae feat: match store name 2025-10-03 19:59:47 +05:30
Shivam Mishra ac95f2fa2f fix: scope 2025-10-03 19:59:39 +05:30
Shivam Mishra e4631e4294 feat: allow edit 2025-10-03 19:48:50 +05:30
Shivam Mishra 5b8554d216 fix: click binding 2025-10-03 19:22:56 +05:30
Shivam Mishra af528353a2 fix: escape 2025-10-03 19:21:36 +05:30
Shivam Mishra 59bfe99295 fix: timestamp 2025-10-03 19:19:38 +05:30
Shivam Mishra 6947ea7f04 feat: better tool card 2025-10-03 19:19:34 +05:30
Shivam Mishra 90d4fcf95d fix: tool class name 2025-10-03 19:09:09 +05:30
Shivam Mishra 8dfc09dc35 fix: design 2025-10-03 18:43:03 +05:30
Shivam Mishra 650685a2e0 feat: add custom tool dialog 2025-10-03 18:15:24 +05:30
Shivam Mishra 3e3d5dda80 feat: add custom tools page 2025-10-03 18:02:23 +05:30
Shivam Mishra a54ad12bf1 feat: add custom tools frontend 2025-10-03 17:37:45 +05:30
Shivam Mishra 24bbf9f16c feat: add controller for custom tools 2025-10-03 17:37:24 +05:30
Shivam MishraandGitHub a4c15f4ea7 Merge branch 'develop' into feat/captain-product-flows 2025-10-03 17:02:20 +05:30
Shivam Mishra 77676d4a2f feat: stricter liquid rendering 2025-10-03 17:02:01 +05:30
Shivam Mishra 03af966d87 feat: add some safety against DNS rebinding 2025-10-03 16:59:45 +05:30
Shivam Mishra 62fead60a8 feat: add safe endpoint validation 2025-10-03 16:56:17 +05:30
Shivam Mishra 8ceefacfac feat: add schema validation 2025-10-03 16:29:33 +05:30
Shivam Mishra 8b9b2f75fe feat: enable tool declaration with params 2025-10-03 16:26:04 +05:30
Shivam Mishra 717a9fc919 feat: add params 2025-10-03 16:23:13 +05:30
Shivam Mishra 9f583ae3b1 feat: handle custom tools in scenario via assistant 2025-10-03 16:15:14 +05:30
Shivam Mishra 15555c80bd feat: include custom flag in custom tool metadata 2025-10-03 16:14:55 +05:30
Shivam Mishra da49e60d5b feat: handle custom tools in assistant 2025-10-03 16:13:22 +05:30
Shivam Mishra 6d3e0f473a fix: feature flag name 2025-10-03 15:59:02 +05:30
Shivam Mishra ccda8a77e5 feat: integrate custom tools with Captain scenarios
Add support for custom HTTP tools in scenario tool resolution and validation:
- Extend resolved_tools to load custom tool metadata from database
- Add resolve_tool_instance method to instantiate HttpTool for custom tools
- Update validation to include account-scoped custom tools in available tools
- Support mixed static and custom tool references in scenarios
- Filter out disabled custom tools at resolution time

Test coverage includes validation, resolution, and execution paths for
custom tools with account isolation and enable/disable states.
2025-10-03 15:53:48 +05:30
Shivam Mishra 99eb27706b feat: add http tool for custom tools 2025-10-03 15:47:40 +05:30
Shivam Mishra aed90b4f00 feat: add toolable concern 2025-10-03 15:40:44 +05:30
Shivam Mishra ac90cb4e31 feat: add custom tool 2025-10-03 15:09:37 +05:30
Shivam Mishra ad84274245 feat: add captain custom tool feature flag 2025-10-03 15:02:53 +05:30
43 changed files with 2772 additions and 98 deletions
@@ -0,0 +1,36 @@
/* global axios */
import ApiClient from '../ApiClient';
class CaptainCustomTools extends ApiClient {
constructor() {
super('captain/custom_tools', { accountScoped: true });
}
get({ page = 1, searchKey } = {}) {
return axios.get(this.url, {
params: { page, searchKey },
});
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data = {}) {
return axios.post(this.url, {
custom_tool: data,
});
}
update(id, data = {}) {
return axios.put(`${this.url}/${id}`, {
custom_tool: data,
});
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainCustomTools();
@@ -10,6 +10,10 @@ const props = defineProps({
type: String,
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;
@@ -0,0 +1,73 @@
<script setup>
import { defineModel, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Input from 'dashboard/components-next/input/Input.vue';
const props = defineProps({
authType: {
type: String,
required: true,
validator: value => ['none', 'bearer', 'basic', 'api_key'].includes(value),
},
});
const { t } = useI18n();
const authConfig = defineModel('authConfig', {
type: Object,
default: () => ({}),
});
watch(
() => props.authType,
() => {
authConfig.value = {};
}
);
</script>
<template>
<div class="flex flex-col gap-2">
<Input
v-if="authType === 'bearer'"
v-model="authConfig.token"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.BEARER_TOKEN_PLACEHOLDER')
"
/>
<template v-else-if="authType === 'basic'">
<Input
v-model="authConfig.username"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.USERNAME_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.password"
type="password"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.PASSWORD_PLACEHOLDER')
"
/>
</template>
<template v-else-if="authType === 'api_key'">
<Input
v-model="authConfig.name"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_KEY_PLACEHOLDER')
"
/>
<Input
v-model="authConfig.key"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_CONFIG.API_VALUE_PLACEHOLDER')
"
/>
</template>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { parseAPIErrorResponse } from 'dashboard/store/utils/api';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import CustomToolForm from './CustomToolForm.vue';
const props = defineProps({
selectedTool: {
type: Object,
default: () => ({}),
},
type: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const updateTool = toolDetails =>
store.dispatch('captainCustomTools/update', {
id: props.selectedTool.id,
...toolDetails,
});
const i18nKey = computed(
() => `CAPTAIN.CUSTOM_TOOLS.${props.type.toUpperCase()}`
);
const createTool = toolDetails =>
store.dispatch('captainCustomTools/create', toolDetails);
const handleSubmit = async updatedTool => {
try {
if (props.type === 'edit') {
await updateTool(updatedTool);
} else {
await createTool(updatedTool);
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
dialogRef.value.close();
} catch (error) {
const errorMessage =
parseAPIErrorResponse(error) || t(`${i18nKey.value}.ERROR_MESSAGE`);
useAlert(errorMessage);
}
};
const handleClose = () => {
emit('close');
};
const handleCancel = () => {
dialogRef.value.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
width="2xl"
:title="$t(`${i18nKey}.TITLE`)"
:description="$t('CAPTAIN.CUSTOM_TOOLS.FORM_DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<CustomToolForm
:mode="type"
:tool="selectedTool"
@submit="handleSubmit"
@cancel="handleCancel"
/>
<template #footer />
</Dialog>
</template>
@@ -0,0 +1,125 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
id: {
type: Number,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
default: '',
},
authType: {
type: String,
default: 'none',
},
updatedAt: {
type: Number,
required: true,
},
createdAt: {
type: Number,
required: true,
},
});
const emit = defineEmits(['action']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
value: 'edit',
action: 'edit',
icon: 'i-lucide-pencil-line',
},
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.DELETE_TOOL'),
value: 'delete',
action: 'delete',
icon: 'i-lucide-trash',
},
]);
const timestamp = computed(() =>
dynamicTime(props.updatedAt || props.createdAt)
);
const handleAction = ({ action, value }) => {
toggleDropdown(false);
emit('action', { action, value, id: props.id });
};
const authTypeLabel = computed(() => {
return t(
`CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.${props.authType.toUpperCase()}`
);
});
</script>
<template>
<CardLayout class="relative">
<div class="flex relative justify-between w-full gap-1">
<span class="text-base text-n-slate-12 line-clamp-1 font-medium">
{{ title }}
</span>
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
icon="i-lucide-ellipsis-vertical"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showActionsDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:right-0 top-full"
@action="handleAction($event)"
/>
</Policy>
</div>
</div>
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-3 flex-1">
<span
v-if="description"
class="text-sm truncate text-n-slate-11 flex-1"
>
{{ description }}
</span>
<span
v-if="authType !== 'none'"
class="text-sm shrink-0 text-n-slate-11 inline-flex items-center gap-1"
>
<i class="i-lucide-lock text-base" />
{{ authTypeLabel }}
</span>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
{{ timestamp }}
</span>
</div>
</CardLayout>
</template>
@@ -0,0 +1,271 @@
<script setup>
import { reactive, computed, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import ParamRow from './ParamRow.vue';
import AuthConfig from './AuthConfig.vue';
const props = defineProps({
mode: {
type: String,
default: 'create',
validator: value => ['create', 'edit'].includes(value),
},
tool: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('captainCustomTools/getUIFlags'),
};
const initialState = {
title: '',
description: '',
endpoint_url: '',
http_method: 'GET',
request_template: '',
response_template: '',
auth_type: 'none',
auth_config: {},
param_schema: [],
};
const state = reactive({ ...initialState });
// Populate form when in edit mode
watch(
() => props.tool,
newTool => {
if (props.mode === 'edit' && newTool && newTool.id) {
state.title = newTool.title || '';
state.description = newTool.description || '';
state.endpoint_url = newTool.endpoint_url || '';
state.http_method = newTool.http_method || 'GET';
state.request_template = newTool.request_template || '';
state.response_template = newTool.response_template || '';
state.auth_type = newTool.auth_type || 'none';
state.auth_config = newTool.auth_config || {};
state.param_schema = newTool.param_schema || [];
}
},
{ immediate: true }
);
const DEFAULT_PARAM = {
name: '',
type: 'string',
description: '',
required: false,
};
const validationRules = {
title: { required },
endpoint_url: { required },
http_method: { required },
auth_type: { required },
};
const httpMethodOptions = computed(() => [
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
]);
const authTypeOptions = computed(() => [
{ value: 'none', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.NONE') },
{ value: 'bearer', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BEARER') },
{ value: 'basic', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.BASIC') },
{
value: 'api_key',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPES.API_KEY'),
},
]);
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() =>
props.mode === 'edit'
? formState.uiFlags.value.updatingItem
: formState.uiFlags.value.creatingItem
);
const getErrorMessage = (field, errorKey) => {
return v$.value[field].$error
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
: '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
endpoint_url: getErrorMessage('endpoint_url', 'ENDPOINT_URL'),
}));
const paramsRef = useTemplateRef('paramsRef');
const isParamsValid = () => {
if (!paramsRef.value || paramsRef.value.length === 0) {
return true;
}
return paramsRef.value.every(param => param.validate());
};
const removeParam = index => {
state.param_schema.splice(index, 1);
};
const addParam = () => {
state.param_schema.push({ ...DEFAULT_PARAM });
};
const handleCancel = () => emit('cancel');
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid || !isParamsValid()) {
return;
}
emit('submit', state);
};
</script>
<template>
<form
class="flex flex-col px-4 -mx-4 gap-4 max-h-[calc(100vh-200px)] overflow-y-scroll"
@submit.prevent="handleSubmit"
>
<Input
v-model="state.title"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.description"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.DESCRIPTION.PLACEHOLDER')"
:rows="2"
/>
<div class="flex gap-2">
<div class="flex flex-col gap-1 w-28">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.HTTP_METHOD.LABEL') }}
</label>
<ComboBox
v-model="state.http_method"
:options="httpMethodOptions"
class="[&>div>button]:bg-n-alpha-black2 [&_li]:font-mono [&_button]:font-mono [&>div>button]:outline-offset-[-1px]"
/>
</div>
<Input
v-model="state.endpoint_url"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.ENDPOINT_URL.PLACEHOLDER')"
:message="formErrors.endpoint_url"
:message-type="formErrors.endpoint_url ? 'error' : 'info'"
class="flex-1"
/>
</div>
<div class="flex flex-col gap-1">
<label class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.AUTH_TYPE.LABEL') }}
</label>
<ComboBox
v-model="state.auth_type"
:options="authTypeOptions"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<AuthConfig
v-model:auth-config="state.auth_config"
:auth-type="state.auth_type"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.LABEL') }}
</label>
<p class="text-xs text-n-slate-11 -mt-1">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAMETERS.HELP_TEXT') }}
</p>
<ul v-if="state.param_schema.length > 0" class="grid gap-2 list-none">
<ParamRow
v-for="(param, index) in state.param_schema"
:key="index"
ref="paramsRef"
v-model:name="param.name"
v-model:type="param.type"
v-model:description="param.description"
v-model:required="param.required"
@remove="removeParam(index)"
/>
</ul>
<Button
type="button"
sm
ghost
blue
icon="i-lucide-plus"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.ADD_PARAMETER')"
@click="addParam"
/>
</div>
<TextArea
v-if="state.http_method === 'POST'"
v-model="state.request_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.LABEL')"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.REQUEST_TEMPLATE.PLACEHOLDER')"
:rows="4"
class="[&_textarea]:font-mono"
/>
<TextArea
v-model="state.response_template"
:label="t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.LABEL')"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.RESPONSE_TEMPLATE.PLACEHOLDER')
"
:rows="4"
class="[&_textarea]:font-mono"
/>
<div class="flex gap-3 justify-between items-center w-full">
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAPTAIN.FORM.CANCEL')"
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(mode === 'edit' ? 'CAPTAIN.FORM.EDIT' : 'CAPTAIN.FORM.CREATE')
"
class="w-full"
:is-loading="isLoading"
:disabled="isLoading"
/>
</div>
</form>
</template>
@@ -0,0 +1,113 @@
<script setup>
import { computed, defineModel, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const emit = defineEmits(['remove']);
const { t } = useI18n();
const showErrors = ref(false);
const name = defineModel('name', {
type: String,
required: true,
});
const type = defineModel('type', {
type: String,
required: true,
});
const description = defineModel('description', {
type: String,
default: '',
});
const required = defineModel('required', {
type: Boolean,
default: false,
});
const paramTypeOptions = computed(() => [
{ value: 'string', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.STRING') },
{ value: 'number', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.NUMBER') },
{
value: 'boolean',
label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.BOOLEAN'),
},
{ value: 'array', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.ARRAY') },
{ value: 'object', label: t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPES.OBJECT') },
]);
const validationError = computed(() => {
if (!name.value || name.value.trim() === '') {
return 'PARAM_NAME_REQUIRED';
}
return null;
});
watch([name, type, description, required], () => {
showErrors.value = false;
});
const validate = () => {
showErrors.value = true;
return !validationError.value;
};
defineExpose({ validate });
</script>
<template>
<li class="list-none">
<div
class="flex items-start gap-2 p-3 rounded-lg border border-n-weak bg-n-alpha-2"
:class="{
'animate-wiggle border-n-ruby-9': showErrors && validationError,
}"
>
<div class="flex flex-col flex-1 gap-3">
<div class="grid grid-cols-3 gap-2">
<Input
v-model="name"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_NAME.PLACEHOLDER')"
class="col-span-2"
/>
<ComboBox
v-model="type"
:options="paramTypeOptions"
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_TYPE.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="description"
:placeholder="
t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_DESCRIPTION.PLACEHOLDER')
"
/>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="required" />
<span class="text-sm text-n-slate-11">
{{ t('CAPTAIN.CUSTOM_TOOLS.FORM.PARAM_REQUIRED.LABEL') }}
</span>
</label>
</div>
<Button
solid
slate
icon="i-lucide-trash"
class="flex-shrink-0"
@click.stop="emit('remove')"
/>
</div>
<span
v-if="showErrors && validationError"
class="block mt-1 text-sm text-n-ruby-11"
>
{{ t(`CAPTAIN.CUSTOM_TOOLS.FORM.ERRORS.${validationError}`) }}
</span>
</li>
</template>
@@ -0,0 +1,29 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const emit = defineEmits(['click']);
const onClick = () => {
emit('click');
};
</script>
<template>
<EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
>
<template #empty-state-item>
<div class="min-h-[600px]" />
</template>
<template #actions>
<Button
:label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
icon="i-lucide-plus"
@click="onClick"
/>
</template>
</EmptyStateLayout>
</template>
@@ -232,6 +232,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'),
},
],
},
{
@@ -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",
@@ -304,6 +304,7 @@
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@@ -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,
],
},
},
];
@@ -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>
@@ -0,0 +1,35 @@
import CaptainCustomTools from 'dashboard/api/captain/customTools';
import { createStore } from './storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainCustomTool',
API: CaptainCustomTools,
actions: mutations => ({
update: async ({ commit }, { id, ...updateObj }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainCustomTools.update(id, updateObj);
commit(mutations.EDIT, response.data);
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return response.data;
} catch (error) {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
return throwErrorMessage(error);
}
},
delete: async ({ commit }, id) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainCustomTools.delete(id);
commit(mutations.DELETE, id);
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return id;
} catch (error) {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
return throwErrorMessage(error);
}
},
}),
});
@@ -3,7 +3,7 @@ import CaptainToolsAPI from '../../api/captain/tools';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const toolsStore = createStore({
name: 'captainTool',
name: 'Tools',
API: CaptainToolsAPI,
actions: mutations => ({
getTools: async ({ commit }) => {
+2
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,
});
+1
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]
@@ -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
+20 -1
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
@@ -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
@@ -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
@@ -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,
@@ -0,0 +1,91 @@
# == Schema Information
#
# Table name: captain_custom_tools
#
# id :bigint not null, primary key
# auth_config :jsonb
# auth_type :string default("none")
# description :text
# enabled :boolean default(TRUE), not null
# endpoint_url :text not null
# http_method :string default("GET"), not null
# param_schema :jsonb
# request_template :text
# response_template :text
# slug :string not null
# title :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_captain_custom_tools_on_account_id (account_id)
# index_captain_custom_tools_on_account_id_and_slug (account_id,slug) UNIQUE
#
class Captain::CustomTool < ApplicationRecord
include Concerns::Toolable
include Concerns::SafeEndpointValidatable
self.table_name = 'captain_custom_tools'
PARAM_SCHEMA_VALIDATION = {
'type': 'array',
'items': {
'type': 'object',
'properties': {
'name': { 'type': 'string' },
'type': { 'type': 'string' },
'description': { 'type': 'string' },
'required': { 'type': 'boolean' }
},
'required': %w[name type description],
'additionalProperties': false
}
}.to_json.freeze
belongs_to :account
enum :http_method, %w[GET POST].index_by(&:itself), validate: true
enum :auth_type, %w[none bearer basic api_key].index_by(&:itself), default: :none, validate: true, prefix: :auth
before_validation :generate_slug
validates :slug, presence: true, uniqueness: { scope: :account_id }
validates :title, presence: true
validates :endpoint_url, presence: true
validates_with JsonSchemaValidator,
schema: PARAM_SCHEMA_VALIDATION,
attribute_resolver: ->(record) { record.param_schema }
scope :enabled, -> { where(enabled: true) }
def to_tool_metadata
{
id: slug,
title: title,
description: description,
custom: true
}
end
private
def generate_slug
return if slug.present?
base_slug = title.present? ? "custom_#{title.parameterize}" : "custom_#{SecureRandom.uuid}"
self.slug = find_unique_slug(base_slug)
end
def find_unique_slug(base_slug, counter = 0)
slug_candidate = counter.zero? ? base_slug : "#{base_slug}-#{counter}"
return find_unique_slug(base_slug, counter + 1) if slug_exists?(slug_candidate)
slug_candidate
end
def slug_exists?(candidate)
self.class.exists?(account_id: account_id, slug: candidate)
end
end
+21 -6
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,7 +60,7 @@ class Captain::Scenario < ApplicationRecord
end
def agent_tools
resolved_tools.map { |tool| self.class.resolve_tool_class(tool[:id]) }.map { |tool| tool.new(assistant) }
resolved_tools.map { |tool| resolve_tool_instance(tool) }
end
def resolved_instructions
@@ -69,12 +72,24 @@ class Captain::Scenario < ApplicationRecord
def resolved_tools
return [] if tools.blank?
available_tools = self.class.available_agent_tools
available_tools = assistant.available_agent_tools
tools.filter_map do |tool_id|
available_tools.find { |tool| tool[:id] == tool_id }
end
end
def resolve_tool_instance(tool_metadata)
tool_id = tool_metadata[:id]
if tool_metadata[:custom]
custom_tool = Captain::CustomTool.find_by(slug: tool_id, account_id: account_id, enabled: true)
custom_tool&.tool(assistant)
else
tool_class = self.class.resolve_tool_class(tool_id)
tool_class&.new(assistant)
end
end
# Validates that all tool references in the instruction are valid.
# Parses the instruction for tool references and checks if they exist
# in the available tools configuration.
@@ -95,8 +110,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?
@@ -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
@@ -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
@@ -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 })
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
@@ -9,7 +9,9 @@ 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_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
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'
@@ -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
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
@@ -0,0 +1,10 @@
json.payload do
json.array! @custom_tools do |custom_tool|
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: custom_tool
end
end
json.meta do
json.total_count @custom_tools.count
json.page 1
end
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
@@ -0,0 +1 @@
json.partial! 'api/v1/models/captain/custom_tool', custom_tool: @custom_tool
@@ -0,0 +1,15 @@
json.id custom_tool.id
json.slug custom_tool.slug
json.title custom_tool.title
json.description custom_tool.description
json.endpoint_url custom_tool.endpoint_url
json.http_method custom_tool.http_method
json.request_template custom_tool.request_template
json.response_template custom_tool.response_template
json.auth_type custom_tool.auth_type
json.auth_config custom_tool.auth_config
json.param_schema custom_tool.param_schema
json.enabled custom_tool.enabled
json.account_id custom_tool.account_id
json.created_at custom_tool.created_at.to_i
json.updated_at custom_tool.updated_at.to_i
@@ -8,6 +8,7 @@ You are {{name}}, a helpful and knowledgeable assistant. Your role is to provide
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.
{% if conversation || contact -%}
# Current Context
Here's the metadata we have about the current conversation and the contact associated with it:
@@ -19,6 +20,7 @@ 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
+27 -3
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:
+105
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
@@ -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
@@ -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
@@ -0,0 +1,386 @@
require 'rails_helper'
RSpec.describe Captain::CustomTool, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:endpoint_url) }
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
it {
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
}
describe 'slug uniqueness' do
let(:account) { create(:account) }
it 'validates uniqueness of slug scoped to account' do
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test-tool')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:slug]).to include('has already been taken')
end
it 'allows same slug across different accounts' do
account2 = create(:account)
create(:captain_custom_tool, account: account, slug: 'custom_test-tool')
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test-tool')
expect(different_account_tool).to be_valid
end
end
describe 'param_schema validation' do
let(:account) { create(:account) }
it 'is valid with proper param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
])
expect(tool).to be_valid
end
it 'is valid with empty param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [])
expect(tool).to be_valid
end
it 'is invalid when param_schema is missing name' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing type' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing description' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string' }
])
expect(tool).not_to be_valid
end
it 'is invalid with additional properties in param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
])
expect(tool).not_to be_valid
end
it 'is valid when required field is omitted (defaults to optional param)' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).to be_valid
end
end
end
describe 'scopes' do
let(:account) { create(:account) }
describe '.enabled' do
it 'returns only enabled custom tools' do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
expect(described_class.enabled).to include(enabled_tool)
expect(described_class.enabled).not_to include(disabled_tool)
end
end
end
describe 'slug generation' do
let(:account) { create(:account) }
it 'generates slug from title on creation' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
expect(tool.slug).to eq('custom_fetch-order-status')
end
it 'adds custom_ prefix to generated slug' do
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
expect(tool.slug).to start_with('custom_')
end
it 'does not override manually set slug' do
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual-slug')
expect(tool.slug).to eq('custom_manual-slug')
end
it 'handles slug collisions by appending counter' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool2.slug).to eq('custom_test-tool-1')
end
it 'handles multiple slug collisions' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool')
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test-tool-1')
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool3.slug).to eq('custom_test-tool-2')
end
it 'generates slug with UUID when title is blank' do
tool = build(:captain_custom_tool, account: account, title: nil)
tool.valid?
expect(tool.slug).to match(/^custom_[0-9a-f-]+$/)
end
it 'parameterizes title correctly' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
expect(tool.slug).to eq('custom_fetch-order-status-details')
end
end
describe 'factory' do
it 'creates a valid custom tool with default attributes' do
tool = create(:captain_custom_tool)
expect(tool).to be_valid
expect(tool.title).to be_present
expect(tool.slug).to be_present
expect(tool.endpoint_url).to be_present
expect(tool.http_method).to eq('GET')
expect(tool.auth_type).to eq('none')
expect(tool.enabled).to be true
end
it 'creates valid tool with POST trait' do
tool = create(:captain_custom_tool, :with_post)
expect(tool.http_method).to eq('POST')
expect(tool.request_template).to be_present
end
it 'creates valid tool with bearer auth trait' do
tool = create(:captain_custom_tool, :with_bearer_auth)
expect(tool.auth_type).to eq('bearer')
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
end
it 'creates valid tool with basic auth trait' do
tool = create(:captain_custom_tool, :with_basic_auth)
expect(tool.auth_type).to eq('basic')
expect(tool.auth_config['username']).to eq('test_user')
expect(tool.auth_config['password']).to eq('test_pass')
end
it 'creates valid tool with api key trait' do
tool = create(:captain_custom_tool, :with_api_key)
expect(tool.auth_type).to eq('api_key')
expect(tool.auth_config['key']).to eq('test_api_key')
expect(tool.auth_config['location']).to eq('header')
end
end
describe 'Toolable concern' do
let(:account) { create(:account) }
describe '#build_request_url' do
it 'returns static URL when no template variables present' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
end
it 'renders URL template with params' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
end
it 'handles multiple template variables' do
tool = create(:captain_custom_tool, account: account,
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
expect(result).to eq('https://api.example.com/orders/123?details=true')
end
end
describe '#build_request_body' do
it 'returns nil when request_template is blank' do
tool = create(:captain_custom_tool, account: account, request_template: nil)
expect(tool.build_request_body({})).to be_nil
end
it 'renders request body template with params' do
tool = create(:captain_custom_tool, account: account,
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
result = tool.build_request_body({ order_id: '12345' })
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
end
end
describe '#build_auth_headers' do
it 'returns empty hash for none auth type' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_auth_headers).to eq({})
end
it 'returns bearer token header' do
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
end
it 'returns API key header when location is header' do
tool = create(:captain_custom_tool, :with_api_key, account: account)
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
end
it 'returns empty hash for API key when location is not header' do
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
expect(tool.build_auth_headers).to eq({})
end
it 'returns empty hash for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_auth_headers).to eq({})
end
end
describe '#build_basic_auth_credentials' do
it 'returns nil for non-basic auth types' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_basic_auth_credentials).to be_nil
end
it 'returns username and password array for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
end
end
describe '#format_response' do
it 'returns raw response when no response_template' do
tool = create(:captain_custom_tool, account: account, response_template: nil)
expect(tool.format_response('raw response')).to eq('raw response')
end
it 'renders response template with JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order status: {{ response.status }}')
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order status: shipped')
end
it 'handles response template with multiple fields' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
end
it 'handles non-JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Response: {{ response }}')
raw_response = 'plain text response'
result = tool.format_response(raw_response)
expect(result).to eq('Response: plain text response')
end
end
describe '#to_tool_metadata' do
it 'returns tool metadata hash with custom flag' do
tool = create(:captain_custom_tool, account: account,
slug: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool')
metadata = tool.to_tool_metadata
expect(metadata).to eq({
id: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool',
custom: true
})
end
end
describe '#tool' do
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns HttpTool instance' do
tool = create(:captain_custom_tool, account: account)
tool_instance = tool.tool(assistant)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'sets description on the tool class' do
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
tool_instance = tool.tool(assistant)
expect(tool_instance.description).to eq('Fetches order data')
end
it 'sets parameters on the tool class' do
tool = create(:captain_custom_tool, :with_params, account: account)
tool_instance = tool.tool(assistant)
params = tool_instance.parameters
expect(params.keys).to contain_exactly(:order_id, :include_details)
expect(params[:order_id].name).to eq(:order_id)
expect(params[:order_id].type).to eq('string')
expect(params[:order_id].description).to eq('The order ID')
expect(params[:order_id].required).to be true
expect(params[:include_details].name).to eq(:include_details)
expect(params[:include_details].required).to be false
end
it 'works with empty param_schema' do
tool = create(:captain_custom_tool, account: account, param_schema: [])
tool_instance = tool.tool(assistant)
expect(tool_instance.parameters).to be_empty
end
end
end
end
+180 -3
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)
@@ -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)'
+51
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