Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9f21602aa | ||
|
|
3ca83f3a46 | ||
|
|
488886aed2 | ||
|
|
3709a0ceed | ||
|
|
04847b981a | ||
|
|
55acadcef3 | ||
|
|
7c72a2b4e4 | ||
|
|
7440a7c39b | ||
|
|
fe40537213 | ||
|
|
3549d6f30b | ||
|
|
39d4019349 | ||
|
|
bc0e648380 | ||
|
|
81a4d0c5ad | ||
|
|
49111748ca | ||
|
|
b99d77b81e | ||
|
|
27fd89e1f2 | ||
|
|
1c2ae1cdf6 | ||
|
|
adad39accb | ||
|
|
83b3a4b7dc | ||
|
|
23071bd2d6 | ||
|
|
15a962b196 | ||
|
|
cc64bdb920 | ||
|
|
62e6bddb64 | ||
|
|
8f7fdf4d7e | ||
|
|
0e3f502d4a | ||
|
|
99c6e88226 | ||
|
|
1025e24a23 | ||
|
|
d570020a9b | ||
|
|
06bdb65d37 | ||
|
|
42194646eb | ||
|
|
32ac1a93fe | ||
|
|
ed9e187438 | ||
|
|
78fd9a5efe | ||
|
|
a86cab7594 | ||
|
|
cd914a358d | ||
|
|
49a7769836 | ||
|
|
be5799212c | ||
|
|
a82b13df2d | ||
|
|
70112bf48f | ||
|
|
a7ff1cbb25 | ||
|
|
3eb14f6861 | ||
|
|
7a31f2dad2 | ||
|
|
eea99df480 | ||
|
|
4fcc389c11 | ||
|
|
5691c9808d | ||
|
|
db7ab32987 |
@@ -1688,9 +1688,9 @@ type PublicWorkspaceDataSummary {
|
||||
displayName: String
|
||||
}
|
||||
|
||||
type NativeModelCapabilities {
|
||||
webSearch: Boolean
|
||||
twitterSearch: Boolean
|
||||
type AgentCapabilities {
|
||||
webSearch: Boolean!
|
||||
twitterSearch: Boolean!
|
||||
}
|
||||
|
||||
type ClientAiModelConfig {
|
||||
@@ -1701,7 +1701,7 @@ type ClientAiModelConfig {
|
||||
sdkPackage: String
|
||||
inputCostPerMillionTokens: Float
|
||||
outputCostPerMillionTokens: Float
|
||||
nativeCapabilities: NativeModelCapabilities
|
||||
capabilities: AgentCapabilities!
|
||||
isDeprecated: Boolean
|
||||
isRecommended: Boolean
|
||||
providerName: String
|
||||
@@ -1785,6 +1785,7 @@ type ClientConfig {
|
||||
analyticsEnabled: Boolean!
|
||||
support: Support!
|
||||
isAttachmentPreviewEnabled: Boolean!
|
||||
isCodeInterpreterEnabled: Boolean!
|
||||
sentry: Sentry!
|
||||
captcha: Captcha!
|
||||
api: ApiConfig!
|
||||
|
||||
@@ -1385,10 +1385,10 @@ export interface PublicWorkspaceDataSummary {
|
||||
__typename: 'PublicWorkspaceDataSummary'
|
||||
}
|
||||
|
||||
export interface NativeModelCapabilities {
|
||||
webSearch?: Scalars['Boolean']
|
||||
twitterSearch?: Scalars['Boolean']
|
||||
__typename: 'NativeModelCapabilities'
|
||||
export interface AgentCapabilities {
|
||||
webSearch: Scalars['Boolean']
|
||||
twitterSearch: Scalars['Boolean']
|
||||
__typename: 'AgentCapabilities'
|
||||
}
|
||||
|
||||
export interface ClientAiModelConfig {
|
||||
@@ -1399,7 +1399,7 @@ export interface ClientAiModelConfig {
|
||||
sdkPackage?: Scalars['String']
|
||||
inputCostPerMillionTokens?: Scalars['Float']
|
||||
outputCostPerMillionTokens?: Scalars['Float']
|
||||
nativeCapabilities?: NativeModelCapabilities
|
||||
capabilities: AgentCapabilities
|
||||
isDeprecated?: Scalars['Boolean']
|
||||
isRecommended?: Scalars['Boolean']
|
||||
providerName?: Scalars['String']
|
||||
@@ -1480,6 +1480,7 @@ export interface ClientConfig {
|
||||
analyticsEnabled: Scalars['Boolean']
|
||||
support: Support
|
||||
isAttachmentPreviewEnabled: Scalars['Boolean']
|
||||
isCodeInterpreterEnabled: Scalars['Boolean']
|
||||
sentry: Sentry
|
||||
captcha: Captcha
|
||||
api: ApiConfig
|
||||
@@ -4303,7 +4304,7 @@ export interface PublicWorkspaceDataSummaryGenqlSelection{
|
||||
__scalar?: boolean | number
|
||||
}
|
||||
|
||||
export interface NativeModelCapabilitiesGenqlSelection{
|
||||
export interface AgentCapabilitiesGenqlSelection{
|
||||
webSearch?: boolean | number
|
||||
twitterSearch?: boolean | number
|
||||
__typename?: boolean | number
|
||||
@@ -4318,7 +4319,7 @@ export interface ClientAiModelConfigGenqlSelection{
|
||||
sdkPackage?: boolean | number
|
||||
inputCostPerMillionTokens?: boolean | number
|
||||
outputCostPerMillionTokens?: boolean | number
|
||||
nativeCapabilities?: NativeModelCapabilitiesGenqlSelection
|
||||
capabilities?: AgentCapabilitiesGenqlSelection
|
||||
isDeprecated?: boolean | number
|
||||
isRecommended?: boolean | number
|
||||
providerName?: boolean | number
|
||||
@@ -4402,6 +4403,7 @@ export interface ClientConfigGenqlSelection{
|
||||
analyticsEnabled?: boolean | number
|
||||
support?: SupportGenqlSelection
|
||||
isAttachmentPreviewEnabled?: boolean | number
|
||||
isCodeInterpreterEnabled?: boolean | number
|
||||
sentry?: SentryGenqlSelection
|
||||
captcha?: CaptchaGenqlSelection
|
||||
api?: ApiConfigGenqlSelection
|
||||
@@ -7139,10 +7141,10 @@ export interface LogicFunctionLogsInput {applicationId?: (Scalars['UUID'] | null
|
||||
|
||||
|
||||
|
||||
const NativeModelCapabilities_possibleTypes: string[] = ['NativeModelCapabilities']
|
||||
export const isNativeModelCapabilities = (obj?: { __typename?: any } | null): obj is NativeModelCapabilities => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isNativeModelCapabilities"')
|
||||
return NativeModelCapabilities_possibleTypes.includes(obj.__typename)
|
||||
const AgentCapabilities_possibleTypes: string[] = ['AgentCapabilities']
|
||||
export const isAgentCapabilities = (obj?: { __typename?: any } | null): obj is AgentCapabilities => {
|
||||
if (!obj?.__typename) throw new Error('__typename is missing in "isAgentCapabilities"')
|
||||
return AgentCapabilities_possibleTypes.includes(obj.__typename)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3380,7 +3380,7 @@ export default {
|
||||
1
|
||||
]
|
||||
},
|
||||
"NativeModelCapabilities": {
|
||||
"AgentCapabilities": {
|
||||
"webSearch": [
|
||||
6
|
||||
],
|
||||
@@ -3413,7 +3413,7 @@ export default {
|
||||
"outputCostPerMillionTokens": [
|
||||
11
|
||||
],
|
||||
"nativeCapabilities": [
|
||||
"capabilities": [
|
||||
171
|
||||
],
|
||||
"isDeprecated": [
|
||||
@@ -3578,6 +3578,9 @@ export default {
|
||||
"isAttachmentPreviewEnabled": [
|
||||
6
|
||||
],
|
||||
"isCodeInterpreterEnabled": [
|
||||
6
|
||||
],
|
||||
"sentry": [
|
||||
177
|
||||
],
|
||||
|
||||
@@ -48,6 +48,12 @@ export type Agent = {
|
||||
updatedAt: Scalars['DateTime'];
|
||||
};
|
||||
|
||||
export type AgentCapabilities = {
|
||||
__typename?: 'AgentCapabilities';
|
||||
twitterSearch: Scalars['Boolean'];
|
||||
webSearch: Scalars['Boolean'];
|
||||
};
|
||||
|
||||
export type AgentChatEvent = {
|
||||
__typename?: 'AgentChatEvent';
|
||||
event: Scalars['JSON'];
|
||||
@@ -812,6 +818,7 @@ export type CheckUserExist = {
|
||||
|
||||
export type ClientAiModelConfig = {
|
||||
__typename?: 'ClientAiModelConfig';
|
||||
capabilities: AgentCapabilities;
|
||||
contextWindowTokens?: Maybe<Scalars['Float']>;
|
||||
dataResidency?: Maybe<Scalars['String']>;
|
||||
inputCostPerMillionTokens?: Maybe<Scalars['Float']>;
|
||||
@@ -822,7 +829,6 @@ export type ClientAiModelConfig = {
|
||||
modelFamily?: Maybe<ModelFamily>;
|
||||
modelFamilyLabel?: Maybe<Scalars['String']>;
|
||||
modelId: Scalars['String'];
|
||||
nativeCapabilities?: Maybe<NativeModelCapabilities>;
|
||||
outputCostPerMillionTokens?: Maybe<Scalars['Float']>;
|
||||
providerLabel?: Maybe<Scalars['String']>;
|
||||
providerName?: Maybe<Scalars['String']>;
|
||||
@@ -846,6 +852,7 @@ export type ClientConfig = {
|
||||
isAttachmentPreviewEnabled: Scalars['Boolean'];
|
||||
isClickHouseConfigured: Scalars['Boolean'];
|
||||
isCloudflareIntegrationEnabled: Scalars['Boolean'];
|
||||
isCodeInterpreterEnabled: Scalars['Boolean'];
|
||||
isConfigVariablesInDbEnabled: Scalars['Boolean'];
|
||||
isEmailVerificationRequired: Scalars['Boolean'];
|
||||
isGoogleCalendarEnabled: Scalars['Boolean'];
|
||||
@@ -3558,12 +3565,6 @@ export type MutationVerifyTwoFactorAuthenticationMethodForAuthenticatedUserArgs
|
||||
otp: Scalars['String'];
|
||||
};
|
||||
|
||||
export type NativeModelCapabilities = {
|
||||
__typename?: 'NativeModelCapabilities';
|
||||
twitterSearch?: Maybe<Scalars['Boolean']>;
|
||||
webSearch?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
export type NavigationMenuItem = {
|
||||
__typename?: 'NavigationMenuItem';
|
||||
applicationId?: Maybe<Scalars['UUID']>;
|
||||
|
||||
+60
-72
@@ -1,51 +1,25 @@
|
||||
import { aiModelsState } from '@/client-config/states/aiModelsState';
|
||||
import { isCodeInterpreterAvailableState } from '@/client-config/states/isCodeInterpreterAvailableState';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
|
||||
import { styled } from '@linaria/react';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useContext } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconBrandX, IconWorld } from 'twenty-ui/display';
|
||||
import { Checkbox } from 'twenty-ui/input';
|
||||
import {
|
||||
type AgentCapability,
|
||||
isAgentCapabilityEnabled,
|
||||
type ModelConfiguration,
|
||||
} from 'twenty-shared/ai';
|
||||
import { IconBrandX, IconCode, IconWorld } from 'twenty-ui/display';
|
||||
import { Section } from 'twenty-ui/layout';
|
||||
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
import { MenuItemToggle } from 'twenty-ui/navigation';
|
||||
import { themeCssVariables } from 'twenty-ui/theme-constants';
|
||||
|
||||
const StyledCheckboxContainer = styled.div<{ disabled: boolean }>`
|
||||
align-items: center;
|
||||
border-radius: ${themeCssVariables.border.radius.sm};
|
||||
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
|
||||
display: flex;
|
||||
height: ${themeCssVariables.spacing[8]};
|
||||
justify-content: space-between;
|
||||
padding-inline: ${themeCssVariables.spacing[2]};
|
||||
transition: background-color
|
||||
calc(${themeCssVariables.animation.duration.normal} * 1s) ease;
|
||||
|
||||
&:hover {
|
||||
background-color: ${({ disabled }) =>
|
||||
disabled
|
||||
? 'transparent'
|
||||
: themeCssVariables.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledCheckboxLabel = styled.div`
|
||||
align-items: center;
|
||||
const StyledCapabilitiesContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${themeCssVariables.spacing[1]};
|
||||
`;
|
||||
|
||||
type ModelConfiguration = {
|
||||
webSearch?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
twitterSearch?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
type SettingsAgentModelCapabilitiesProps = {
|
||||
selectedModelId: string;
|
||||
modelConfiguration: ModelConfiguration;
|
||||
@@ -59,22 +33,24 @@ export const SettingsAgentModelCapabilities = ({
|
||||
onConfigurationChange,
|
||||
disabled = false,
|
||||
}: SettingsAgentModelCapabilitiesProps) => {
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const aiModels = useAtomStateValue(aiModelsState);
|
||||
const isCodeInterpreterAvailable = useAtomStateValue(
|
||||
isCodeInterpreterAvailableState,
|
||||
);
|
||||
|
||||
const selectedModel = aiModels.find((m) => m.modelId === selectedModelId);
|
||||
const nativeCapabilities = selectedModel?.nativeCapabilities;
|
||||
const availableModelCapabilities = selectedModel?.capabilities;
|
||||
|
||||
if (!isDefined(nativeCapabilities)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!nativeCapabilities.webSearch && !nativeCapabilities.twitterSearch) {
|
||||
if (
|
||||
!availableModelCapabilities?.webSearch &&
|
||||
!availableModelCapabilities?.twitterSearch &&
|
||||
!isCodeInterpreterAvailable
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleCapabilityToggle = (
|
||||
capability: 'webSearch' | 'twitterSearch',
|
||||
capability: AgentCapability,
|
||||
enabled: boolean,
|
||||
) => {
|
||||
if (disabled) {
|
||||
@@ -90,56 +66,68 @@ export const SettingsAgentModelCapabilities = ({
|
||||
});
|
||||
};
|
||||
|
||||
const capabilities = [
|
||||
...(nativeCapabilities.webSearch
|
||||
const modelCapabilityItems = [
|
||||
...(availableModelCapabilities?.webSearch
|
||||
? [
|
||||
{
|
||||
key: 'webSearch' as const,
|
||||
label: t`Web Search`,
|
||||
Icon: IconWorld,
|
||||
enabled: modelConfiguration.webSearch?.enabled || false,
|
||||
enabled: isAgentCapabilityEnabled(modelConfiguration, 'webSearch'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(nativeCapabilities.twitterSearch
|
||||
...(availableModelCapabilities?.twitterSearch
|
||||
? [
|
||||
{
|
||||
key: 'twitterSearch' as const,
|
||||
label: t`Twitter/X Search`,
|
||||
Icon: IconBrandX,
|
||||
enabled: modelConfiguration.twitterSearch?.enabled || false,
|
||||
enabled: isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'twitterSearch',
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const workspaceCapabilityItems = isCodeInterpreterAvailable
|
||||
? [
|
||||
{
|
||||
key: 'codeInterpreter' as const,
|
||||
label: t`Code Interpreter`,
|
||||
Icon: IconCode,
|
||||
enabled: isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'codeInterpreter',
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const capabilityItems = [
|
||||
...modelCapabilityItems,
|
||||
...workspaceCapabilityItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<InputLabel>{t`Enable model-specific features`}</InputLabel>
|
||||
<div>
|
||||
{capabilities.map((capability) => (
|
||||
<StyledCheckboxContainer
|
||||
disabled={disabled}
|
||||
<InputLabel>{t`Capabilities`}</InputLabel>
|
||||
<StyledCapabilitiesContainer>
|
||||
{capabilityItems.map((capability) => (
|
||||
<MenuItemToggle
|
||||
key={capability.key}
|
||||
onClick={() =>
|
||||
handleCapabilityToggle(capability.key, !capability.enabled)
|
||||
LeftIcon={capability.Icon}
|
||||
text={capability.label}
|
||||
toggled={capability.enabled}
|
||||
onToggleChange={(toggled) =>
|
||||
handleCapabilityToggle(capability.key, toggled)
|
||||
}
|
||||
>
|
||||
<StyledCheckboxLabel>
|
||||
<capability.Icon size={theme.icon.size.sm} />
|
||||
<span>{capability.label}</span>
|
||||
</StyledCheckboxLabel>
|
||||
<Checkbox
|
||||
checked={capability.enabled}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
handleCapabilityToggle(capability.key, event.target.checked);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</StyledCheckboxContainer>
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</StyledCapabilitiesContainer>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { aiModelsState } from '@/client-config/states/aiModelsState';
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { apiConfigState } from '@/client-config/states/apiConfigState';
|
||||
import { appVersionState } from '@/client-config/states/appVersionState';
|
||||
import { authProvidersState } from '@/client-config/states/authProvidersState';
|
||||
@@ -6,14 +7,15 @@ import { billingState } from '@/client-config/states/billingState';
|
||||
import { calendarBookingPageIdState } from '@/client-config/states/calendarBookingPageIdState';
|
||||
import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeatureFlagsState';
|
||||
import { captchaState } from '@/client-config/states/captchaState';
|
||||
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
|
||||
import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState';
|
||||
import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState';
|
||||
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState';
|
||||
import { isCloudflareIntegrationEnabledState } from '@/client-config/states/isCloudflareIntegrationEnabledState';
|
||||
import { isCodeInterpreterAvailableState } from '@/client-config/states/isCodeInterpreterAvailableState';
|
||||
import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState';
|
||||
import { isDDLLockedState } from '@/client-config/states/isDDLLockedState';
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState';
|
||||
import { isEmailingDomainsEnabledState } from '@/client-config/states/isEmailingDomainsEnabledState';
|
||||
import { isEmailVerificationRequiredState } from '@/client-config/states/isEmailVerificationRequiredState';
|
||||
import { isGoogleCalendarEnabledState } from '@/client-config/states/isGoogleCalendarEnabledState';
|
||||
@@ -23,16 +25,15 @@ import { isMicrosoftCalendarEnabledState } from '@/client-config/states/isMicros
|
||||
import { isMicrosoftMessagingEnabledState } from '@/client-config/states/isMicrosoftMessagingEnabledState';
|
||||
import { isMultiWorkspaceEnabledState } from '@/client-config/states/isMultiWorkspaceEnabledState';
|
||||
import { labPublicFeatureFlagsState } from '@/client-config/states/labPublicFeatureFlagsState';
|
||||
import { maintenanceModeState } from '@/client-config/states/maintenanceModeState';
|
||||
import { sentryConfigState } from '@/client-config/states/sentryConfigState';
|
||||
import { supportChatState } from '@/client-config/states/supportChatState';
|
||||
import { type ClientConfig } from '@/client-config/types/ClientConfig';
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
import { useCallback } from 'react';
|
||||
import { clientConfigApiStatusState } from '@/client-config/states/clientConfigApiStatusState';
|
||||
import { getClientConfig } from '@/client-config/utils/getClientConfig';
|
||||
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { domainConfigurationState } from '@/domain-manager/states/domainConfigurationState';
|
||||
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
|
||||
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
type UseClientConfigResult = {
|
||||
data: { clientConfig: ClientConfig } | undefined;
|
||||
@@ -93,6 +94,9 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
const setIsAttachmentPreviewEnabled = useSetAtomState(
|
||||
isAttachmentPreviewEnabledState,
|
||||
);
|
||||
const setIsCodeInterpreterAvailable = useSetAtomState(
|
||||
isCodeInterpreterAvailableState,
|
||||
);
|
||||
|
||||
const setIsConfigVariablesInDbEnabled = useSetAtomState(
|
||||
isConfigVariablesInDbEnabledState,
|
||||
@@ -185,6 +189,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsGoogleMessagingEnabled(clientConfig?.isGoogleMessagingEnabled);
|
||||
setIsGoogleCalendarEnabled(clientConfig?.isGoogleCalendarEnabled);
|
||||
setIsAttachmentPreviewEnabled(clientConfig?.isAttachmentPreviewEnabled);
|
||||
setIsCodeInterpreterAvailable(clientConfig?.isCodeInterpreterEnabled);
|
||||
setIsConfigVariablesInDbEnabled(
|
||||
clientConfig?.isConfigVariablesInDbEnabled,
|
||||
);
|
||||
@@ -229,6 +234,7 @@ export const useClientConfig = (): UseClientConfigResult => {
|
||||
setIsGoogleMessagingEnabled,
|
||||
setIsAnalyticsEnabled,
|
||||
setIsAttachmentPreviewEnabled,
|
||||
setIsCodeInterpreterAvailable,
|
||||
setIsConfigVariablesInDbEnabled,
|
||||
setIsDeveloperDefaultSignInPrefilled,
|
||||
setIsEmailVerificationRequired,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCodeInterpreterAvailableState = createAtomState<boolean>({
|
||||
key: 'isCodeInterpreterAvailableState',
|
||||
defaultValue: false,
|
||||
});
|
||||
@@ -23,6 +23,7 @@ export type ClientConfig = {
|
||||
defaultSubdomain?: string;
|
||||
frontDomain: string;
|
||||
isAttachmentPreviewEnabled: boolean;
|
||||
isCodeInterpreterEnabled: boolean;
|
||||
isConfigVariablesInDbEnabled: boolean;
|
||||
isEmailVerificationRequired: boolean;
|
||||
isGoogleCalendarEnabled: boolean;
|
||||
|
||||
+1
@@ -38,6 +38,7 @@ const mockClientConfig = {
|
||||
mutationMaximumAffectedRecords: 100,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: false,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
+16
-5
@@ -19,6 +19,8 @@ import {
|
||||
type AgentResponseSchema,
|
||||
type ModelConfiguration,
|
||||
} from 'twenty-shared/ai';
|
||||
import { AUTO_SELECT_SMART_MODEL_ID } from 'twenty-shared/constants';
|
||||
import { isAutoSelectModelId } from 'twenty-shared/utils';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import {
|
||||
UpdateOneAgentDocument,
|
||||
@@ -114,10 +116,19 @@ export const WorkflowAiAgentPromptTab = ({
|
||||
}
|
||||
|
||||
const agent = workflowAiAgentActionAgent;
|
||||
const defaultPinnedOption = pinnedOption
|
||||
? {
|
||||
...pinnedOption,
|
||||
value: null,
|
||||
}
|
||||
: undefined;
|
||||
const selectedModelId = isAutoSelectModelId(agent.modelId)
|
||||
? null
|
||||
: agent.modelId;
|
||||
|
||||
const handleModelChange = async (modelId: string) => {
|
||||
const handleModelChange = async (modelId: string | null) => {
|
||||
await updateAgentField({
|
||||
modelId,
|
||||
modelId: modelId ?? AUTO_SELECT_SMART_MODEL_ID,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -136,12 +147,12 @@ export const WorkflowAiAgentPromptTab = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Select
|
||||
<Select<string | null>
|
||||
label={t`Model`}
|
||||
dropdownId="select-agent-model"
|
||||
options={aiModelOptions}
|
||||
pinnedOption={pinnedOption}
|
||||
value={agent.modelId}
|
||||
pinnedOption={defaultPinnedOption}
|
||||
value={selectedModelId}
|
||||
onChange={handleModelChange}
|
||||
showContextualTextInControl={false}
|
||||
disabled={readonly}
|
||||
|
||||
+6
@@ -25,6 +25,12 @@ export const settingsAiAgentFormSchema = z.object({
|
||||
configuration: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
codeInterpreter: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
configuration: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
responseFormat: z
|
||||
|
||||
@@ -51,6 +51,7 @@ export const mockedClientConfig: ClientConfig = {
|
||||
isGoogleMessagingEnabled: true,
|
||||
isGoogleCalendarEnabled: true,
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
isConfigVariablesInDbEnabled: false,
|
||||
isImapSmtpCaldavEnabled: false,
|
||||
isTwoFactorAuthenticationEnabled: false,
|
||||
|
||||
+5
@@ -52,6 +52,10 @@ describe('ClientConfigController', () => {
|
||||
label: 'GPT-4o',
|
||||
modelFamily: ModelFamily.GPT,
|
||||
sdkPackage: '@ai-sdk/openai' as const,
|
||||
capabilities: {
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
},
|
||||
inputCostPerMillionTokensInCredits: 2500000,
|
||||
outputCostPerMillionTokensInCredits: 10000000,
|
||||
},
|
||||
@@ -85,6 +89,7 @@ describe('ClientConfigController', () => {
|
||||
mutationMaximumAffectedRecords: 100,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: false,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
+12
-8
@@ -29,13 +29,13 @@ registerEnumType(AiModelRole, {
|
||||
name: 'AiModelRole',
|
||||
});
|
||||
|
||||
@ObjectType()
|
||||
export class NativeModelCapabilities {
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
webSearch?: boolean;
|
||||
@ObjectType('AgentCapabilities')
|
||||
export class ClientAiModelCapabilities {
|
||||
@Field(() => Boolean)
|
||||
webSearch: boolean;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
twitterSearch?: boolean;
|
||||
@Field(() => Boolean)
|
||||
twitterSearch: boolean;
|
||||
}
|
||||
|
||||
@ObjectType()
|
||||
@@ -62,8 +62,9 @@ export class ClientAiModelConfig {
|
||||
@Field(() => Number, { nullable: true })
|
||||
outputCostPerMillionTokens?: number;
|
||||
|
||||
@Field(() => NativeModelCapabilities, { nullable: true })
|
||||
nativeCapabilities?: NativeModelCapabilities;
|
||||
@Field(() => ClientAiModelCapabilities)
|
||||
// Model-level availability. Agent-level on/off state lives in modelConfiguration.
|
||||
capabilities: ClientAiModelCapabilities;
|
||||
|
||||
@Field(() => Boolean, { nullable: true })
|
||||
isDeprecated?: boolean;
|
||||
@@ -276,6 +277,9 @@ export class ClientConfig {
|
||||
@Field(() => Boolean)
|
||||
isAttachmentPreviewEnabled: boolean;
|
||||
|
||||
@Field(() => Boolean)
|
||||
isCodeInterpreterEnabled: boolean;
|
||||
|
||||
@Field(() => Sentry)
|
||||
sentry: Sentry;
|
||||
|
||||
|
||||
+124
-1
@@ -3,18 +3,25 @@ import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
|
||||
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
|
||||
import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
|
||||
describe('ClientConfigService', () => {
|
||||
let service: ClientConfigService;
|
||||
let twentyConfigService: TwentyConfigService;
|
||||
let domainServerConfigService: DomainServerConfigService;
|
||||
let aiModelRegistryService: AiModelRegistryService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -57,6 +64,9 @@ describe('ClientConfigService', () => {
|
||||
domainServerConfigService = module.get<DomainServerConfigService>(
|
||||
DomainServerConfigService,
|
||||
);
|
||||
aiModelRegistryService = module.get<AiModelRegistryService>(
|
||||
AiModelRegistryService,
|
||||
);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
@@ -103,6 +113,9 @@ describe('ClientConfigService', () => {
|
||||
CLOUDFLARE_ZONE_ID: undefined,
|
||||
ALLOW_REQUESTS_TO_TWENTY_ICONS: false,
|
||||
CLICKHOUSE_URL: undefined,
|
||||
WEB_SEARCH_DRIVER: WebSearchDriverType.DISABLED,
|
||||
WEB_SEARCH_PREFER_NATIVE: false,
|
||||
CODE_INTERPRETER_TYPE: CodeInterpreterDriverType.DISABLED,
|
||||
};
|
||||
|
||||
return mockValues[key];
|
||||
@@ -162,6 +175,7 @@ describe('ClientConfigService', () => {
|
||||
mutationMaximumAffectedRecords: 1000,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: true,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: PUBLIC_FEATURE_FLAGS,
|
||||
@@ -240,5 +254,114 @@ describe('ClientConfigService', () => {
|
||||
|
||||
expect(result.canManageFeatureFlags).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps x search available when native web search is preferred', async () => {
|
||||
jest
|
||||
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
|
||||
.mockReturnValue([
|
||||
{
|
||||
modelId: 'xai-model',
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
model: {} as never,
|
||||
providerName: 'xai',
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'WEB_SEARCH_DRIVER') return WebSearchDriverType.EXA;
|
||||
if (key === 'WEB_SEARCH_PREFER_NATIVE') return true;
|
||||
if (key === 'CODE_INTERPRETER_TYPE')
|
||||
return CodeInterpreterDriverType.DISABLED;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.getClientConfig();
|
||||
const xaiModel = result.aiModels.find(
|
||||
(model) => model.modelId === 'xai-model',
|
||||
);
|
||||
|
||||
expect(xaiModel?.capabilities).toEqual({
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
});
|
||||
expect(xaiModel?.capabilities).not.toHaveProperty('codeInterpreter');
|
||||
expect(result.isCodeInterpreterEnabled).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps x search available when external web search is preferred', async () => {
|
||||
jest
|
||||
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
|
||||
.mockReturnValue([
|
||||
{
|
||||
modelId: 'xai-model',
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
model: {} as never,
|
||||
providerName: 'xai',
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'WEB_SEARCH_DRIVER') return WebSearchDriverType.EXA;
|
||||
if (key === 'WEB_SEARCH_PREFER_NATIVE') return false;
|
||||
if (key === 'CODE_INTERPRETER_TYPE')
|
||||
return CodeInterpreterDriverType.DISABLED;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.getClientConfig();
|
||||
const xaiModel = result.aiModels.find(
|
||||
(model) => model.modelId === 'xai-model',
|
||||
);
|
||||
|
||||
expect(xaiModel?.capabilities).toEqual({
|
||||
webSearch: true,
|
||||
twitterSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces code interpreter availability at the client-config level', async () => {
|
||||
jest
|
||||
.spyOn(aiModelRegistryService, 'getAdminFilteredModels')
|
||||
.mockReturnValue([
|
||||
{
|
||||
modelId: 'openai-model',
|
||||
sdkPackage: AI_SDK_OPENAI,
|
||||
model: {} as never,
|
||||
providerName: 'openai',
|
||||
},
|
||||
]);
|
||||
|
||||
jest
|
||||
.spyOn(twentyConfigService, 'get')
|
||||
.mockImplementation((key: string) => {
|
||||
if (key === 'WEB_SEARCH_DRIVER') {
|
||||
return WebSearchDriverType.DISABLED;
|
||||
}
|
||||
|
||||
if (key === 'CODE_INTERPRETER_TYPE') {
|
||||
return CodeInterpreterDriverType.LOCAL;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const result = await service.getClientConfig();
|
||||
const openAiModel = result.aiModels.find(
|
||||
(model) => model.modelId === 'openai-model',
|
||||
);
|
||||
|
||||
expect(result.isCodeInterpreterEnabled).toBe(true);
|
||||
expect(openAiModel?.capabilities).toEqual({
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
});
|
||||
expect(openAiModel?.capabilities).not.toHaveProperty('codeInterpreter');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+35
-17
@@ -1,32 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { CodeInterpreterDriverType } from 'src/engine/core-modules/code-interpreter/code-interpreter.interface';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
import { WebSearchDriverType } from 'src/engine/core-modules/web-search/web-search.interface';
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
|
||||
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
|
||||
|
||||
import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service';
|
||||
import {
|
||||
type ClientAiModelCapabilities,
|
||||
type ClientAiModelConfig,
|
||||
type ClientConfig,
|
||||
type NativeModelCapabilities,
|
||||
} from 'src/engine/core-modules/client-config/client-config.entity';
|
||||
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
|
||||
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import {
|
||||
AUTO_SELECT_FAST_MODEL_ID,
|
||||
AUTO_SELECT_SMART_MODEL_ID,
|
||||
} from 'twenty-shared/constants';
|
||||
import { MODEL_FAMILY_LABELS } from 'src/engine/metadata-modules/ai/ai-models/constants/model-family-labels.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
|
||||
@Injectable()
|
||||
export class ClientConfigService {
|
||||
@@ -37,17 +40,23 @@ export class ClientConfigService {
|
||||
private maintenanceModeService: MaintenanceModeService,
|
||||
) {}
|
||||
|
||||
private deriveNativeCapabilities(
|
||||
private deriveModelCapabilities(
|
||||
sdkPackage?: AiSdkPackage,
|
||||
): NativeModelCapabilities | undefined {
|
||||
switch (sdkPackage) {
|
||||
case AI_SDK_OPENAI:
|
||||
case AI_SDK_ANTHROPIC:
|
||||
case AI_SDK_BEDROCK:
|
||||
return { webSearch: true };
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
): ClientAiModelCapabilities {
|
||||
const supportsProviderNativeWebSearch =
|
||||
sdkPackage === AI_SDK_OPENAI ||
|
||||
sdkPackage === AI_SDK_ANTHROPIC ||
|
||||
sdkPackage === AI_SDK_BEDROCK ||
|
||||
sdkPackage === AI_SDK_XAI;
|
||||
|
||||
const isExternalWebSearchEnabled =
|
||||
this.twentyConfigService.get('WEB_SEARCH_DRIVER') !==
|
||||
WebSearchDriverType.DISABLED;
|
||||
|
||||
return {
|
||||
webSearch: supportsProviderNativeWebSearch || isExternalWebSearchEnabled,
|
||||
twitterSearch: sdkPackage === AI_SDK_XAI,
|
||||
};
|
||||
}
|
||||
|
||||
private isCloudflareIntegrationEnabled(): boolean {
|
||||
@@ -95,7 +104,7 @@ export class ClientConfigService {
|
||||
sdkPackage: registeredModel.sdkPackage,
|
||||
providerName,
|
||||
providerLabel: getProviderLabel(providerName),
|
||||
nativeCapabilities: this.deriveNativeCapabilities(
|
||||
capabilities: this.deriveModelCapabilities(
|
||||
registeredModel.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens,
|
||||
@@ -135,6 +144,9 @@ export class ClientConfigService {
|
||||
defaultPerformanceModel?.providerName,
|
||||
),
|
||||
sdkPackage: defaultPerformanceModel?.sdkPackage ?? null,
|
||||
capabilities: this.deriveModelCapabilities(
|
||||
defaultPerformanceModel?.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens:
|
||||
defaultPerformanceModelConfig?.inputCostPerMillionTokens,
|
||||
outputCostPerMillionTokens:
|
||||
@@ -153,6 +165,9 @@ export class ClientConfigService {
|
||||
providerName: defaultSpeedModel?.providerName,
|
||||
providerLabel: getProviderLabel(defaultSpeedModel?.providerName),
|
||||
sdkPackage: defaultSpeedModel?.sdkPackage ?? null,
|
||||
capabilities: this.deriveModelCapabilities(
|
||||
defaultSpeedModel?.sdkPackage,
|
||||
),
|
||||
inputCostPerMillionTokens:
|
||||
defaultSpeedModelConfig?.inputCostPerMillionTokens,
|
||||
outputCostPerMillionTokens:
|
||||
@@ -223,6 +238,9 @@ export class ClientConfigService {
|
||||
isAttachmentPreviewEnabled: this.twentyConfigService.get(
|
||||
'IS_ATTACHMENT_PREVIEW_ENABLED',
|
||||
),
|
||||
isCodeInterpreterEnabled:
|
||||
this.twentyConfigService.get('CODE_INTERPRETER_TYPE') !==
|
||||
CodeInterpreterDriverType.DISABLED,
|
||||
analyticsEnabled: this.twentyConfigService.get('ANALYTICS_ENABLED'),
|
||||
canManageFeatureFlags:
|
||||
this.twentyConfigService.get('NODE_ENV') ===
|
||||
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
// Public search tool ids shared across orchestration layers. The runtime can
|
||||
// back these ids with provider-native or external implementations.
|
||||
// one export per file -- to be cleaned after the strategy is clear
|
||||
export const WEB_SEARCH_TOOL_ID = 'web_search';
|
||||
export const X_SEARCH_TOOL_ID = 'x_search';
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
export type ToolProviderAgent = Pick<
|
||||
FlatAgentWithRoleId,
|
||||
'modelId' | 'modelConfiguration'
|
||||
>;
|
||||
+14
-2
@@ -2,17 +2,29 @@ import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
|
||||
export type ToolProviderContext = {
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
executionScope?: 'workflow_agent';
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
agent?: FlatAgentWithRoleId | null;
|
||||
agent?: ToolProviderAgent | null;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
};
|
||||
|
||||
export type ToolContext = Pick<
|
||||
ToolProviderContext,
|
||||
| 'workspaceId'
|
||||
| 'roleId'
|
||||
| 'authContext'
|
||||
| 'actorContext'
|
||||
| 'userId'
|
||||
| 'userWorkspaceId'
|
||||
| 'onCodeExecutionUpdate'
|
||||
>;
|
||||
|
||||
+33
-18
@@ -1,18 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isAgentCapabilityEnabled, ToolCategory } from 'twenty-shared/ai';
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
|
||||
import { type StaticToolHandler } from 'src/engine/core-modules/tool-provider/interfaces/static-tool-handler.interface';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
|
||||
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
|
||||
import { DraftEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/draft-email-tool';
|
||||
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/email-tool/send-email-tool';
|
||||
@@ -80,6 +81,8 @@ export class ActionToolProvider implements ToolProvider {
|
||||
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
|
||||
const includeSchemas = options?.includeSchemas ?? true;
|
||||
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
|
||||
const isWorkflowAgentExecution =
|
||||
context.executionScope === 'workflow_agent';
|
||||
|
||||
const hasHttpPermission = await this.permissionsService.hasToolPermission(
|
||||
context.rolePermissionConfig,
|
||||
@@ -112,24 +115,32 @@ export class ActionToolProvider implements ToolProvider {
|
||||
);
|
||||
}
|
||||
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'search_help_center',
|
||||
this.searchHelpCenterTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
if (!isWorkflowAgentExecution) {
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'search_help_center',
|
||||
this.searchHelpCenterTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'navigate_app',
|
||||
this.navigateAppTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
descriptors.push(
|
||||
this.buildDescriptor(
|
||||
'navigate_app',
|
||||
this.navigateAppTool,
|
||||
includeSchemas,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const agent = context.agent;
|
||||
const isCodeInterpreterEnabledForContext = !isDefined(agent)
|
||||
? true
|
||||
: isAgentCapabilityEnabled(agent.modelConfiguration, 'codeInterpreter');
|
||||
|
||||
const hasCodeInterpreterPermission =
|
||||
this.codeInterpreterService.isEnabled() &&
|
||||
isCodeInterpreterEnabledForContext &&
|
||||
(await this.permissionsService.hasToolPermission(
|
||||
context.rolePermissionConfig,
|
||||
context.workspaceId,
|
||||
@@ -146,7 +157,11 @@ export class ActionToolProvider implements ToolProvider {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.webSearchService.isEnabled()) {
|
||||
const isWebSearchEnabledForContext = !isDefined(agent)
|
||||
? true
|
||||
: isAgentCapabilityEnabled(agent.modelConfiguration, 'webSearch');
|
||||
|
||||
if (this.webSearchService.isEnabled() && isWebSearchEnabledForContext) {
|
||||
descriptors.push(
|
||||
this.buildDescriptor('web_search', this.webSearchTool, includeSchemas),
|
||||
);
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { type AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { type WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
|
||||
import { DatabaseToolProvider } from './database-tool.provider';
|
||||
|
||||
describe('DatabaseToolProvider', () => {
|
||||
const flatField = {
|
||||
id: 'field-id',
|
||||
universalIdentifier: 'company.name',
|
||||
name: 'name',
|
||||
type: FieldMetadataType.TEXT,
|
||||
isNullable: true,
|
||||
isSystem: false,
|
||||
} as const;
|
||||
|
||||
const flatObject = {
|
||||
id: 'object-id',
|
||||
universalIdentifier: 'company',
|
||||
nameSingular: 'company',
|
||||
namePlural: 'companies',
|
||||
labelSingular: 'Company',
|
||||
labelPlural: 'Companies',
|
||||
fieldIds: [flatField.id],
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
icon: 'IconBuilding',
|
||||
} as const;
|
||||
|
||||
const rolesPermissions = {
|
||||
'role-id': {
|
||||
[flatObject.id]: {
|
||||
canReadObjectRecords: true,
|
||||
canUpdateObjectRecords: true,
|
||||
canSoftDeleteObjectRecords: false,
|
||||
canDestroyObjectRecords: false,
|
||||
restrictedFields: {},
|
||||
rowLevelPermissionPredicates: [],
|
||||
rowLevelPermissionPredicateGroups: [],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const flatObjectMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[flatObject.universalIdentifier]: flatObject,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[flatObject.id]: flatObject.universalIdentifier,
|
||||
},
|
||||
};
|
||||
|
||||
const flatFieldMetadataMaps = {
|
||||
byUniversalIdentifier: {
|
||||
[flatField.universalIdentifier]: flatField,
|
||||
},
|
||||
universalIdentifierById: {
|
||||
[flatField.id]: flatField.universalIdentifier,
|
||||
},
|
||||
};
|
||||
|
||||
const createProvider = (sdkPackage: string) => {
|
||||
const workspaceCacheService = {
|
||||
getOrRecompute: jest.fn().mockResolvedValue({
|
||||
rolesPermissions,
|
||||
}),
|
||||
} as Pick<WorkspaceCacheService, 'getOrRecompute'>;
|
||||
|
||||
const flatEntityMapsCacheService = {
|
||||
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
|
||||
flatObjectMetadataMaps,
|
||||
flatFieldMetadataMaps,
|
||||
}),
|
||||
} as Pick<
|
||||
WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
'getOrRecomputeManyOrAllFlatEntityMaps'
|
||||
>;
|
||||
|
||||
const aiModelRegistryService = {
|
||||
resolveModelForAgent: jest.fn().mockReturnValue({
|
||||
sdkPackage,
|
||||
}),
|
||||
} as Pick<AiModelRegistryService, 'resolveModelForAgent'>;
|
||||
|
||||
return new DatabaseToolProvider(
|
||||
workspaceCacheService as WorkspaceCacheService,
|
||||
flatEntityMapsCacheService as WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
aiModelRegistryService as AiModelRegistryService,
|
||||
);
|
||||
};
|
||||
|
||||
const context = {
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'role-id',
|
||||
rolePermissionConfig: { unionOf: ['role-id'] },
|
||||
executionScope: 'workflow_agent' as const,
|
||||
agent: {
|
||||
modelId: 'xai/grok-4',
|
||||
modelConfiguration: {},
|
||||
},
|
||||
};
|
||||
|
||||
it('excludes recursive-filter database tools for xai workflow agents', async () => {
|
||||
const provider = createProvider(AI_SDK_XAI);
|
||||
|
||||
const descriptors = await provider.generateDescriptors(context, {
|
||||
includeSchemas: false,
|
||||
});
|
||||
|
||||
const toolNames = descriptors.map((descriptor) => descriptor.name);
|
||||
|
||||
expect(toolNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'find_one_company',
|
||||
'create_company',
|
||||
'create_many_companies',
|
||||
'update_company',
|
||||
]),
|
||||
);
|
||||
expect(toolNames).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
'find_companies',
|
||||
'group_by_companies',
|
||||
'update_many_companies',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps recursive-filter database tools for non-xai workflow agents', async () => {
|
||||
const provider = createProvider(AI_SDK_OPENAI);
|
||||
|
||||
const descriptors = await provider.generateDescriptors(context, {
|
||||
includeSchemas: false,
|
||||
});
|
||||
|
||||
const toolNames = descriptors.map((descriptor) => descriptor.name);
|
||||
|
||||
expect(toolNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'find_companies',
|
||||
'find_one_company',
|
||||
'group_by_companies',
|
||||
'update_many_companies',
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
+89
-42
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import {
|
||||
type ObjectsPermissions,
|
||||
type ObjectsPermissionsByRoleId,
|
||||
@@ -8,8 +9,8 @@ import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
|
||||
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
|
||||
import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
|
||||
@@ -23,13 +24,20 @@ import {
|
||||
generateGroupByToolInputSchema,
|
||||
hasGroupByToolInputSchema,
|
||||
} from 'src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
|
||||
import { AI_SDK_XAI } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
|
||||
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
|
||||
import { type AiSdkPackage, ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
const WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
STANDARD_OBJECTS.workspaceMember.universalIdentifier;
|
||||
const SDK_PACKAGES_WITHOUT_RECURSIVE_WORKFLOW_DB_TOOL_SUPPORT: ReadonlySet<AiSdkPackage> =
|
||||
new Set([AI_SDK_XAI]);
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseToolProvider implements ToolProvider {
|
||||
@@ -38,6 +46,7 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
constructor(
|
||||
private readonly workspaceCacheService: WorkspaceCacheService,
|
||||
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
) {}
|
||||
|
||||
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
|
||||
@@ -73,6 +82,22 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
},
|
||||
);
|
||||
|
||||
const isWorkflowAgentExecution =
|
||||
context.executionScope === 'workflow_agent';
|
||||
const registeredModel =
|
||||
isWorkflowAgentExecution && context.agent
|
||||
? this.aiModelRegistryService.resolveModelForAgent(context.agent)
|
||||
: undefined;
|
||||
// Some providers (xAI) reject our recursive filter schemas when workflow DB
|
||||
// tools are exposed directly, so exclude only the filter-based tools
|
||||
// in that path.
|
||||
const shouldExcludeRecursiveFilterToolsForWorkflowModel =
|
||||
isWorkflowAgentExecution &&
|
||||
isDefined(registeredModel) &&
|
||||
SDK_PACKAGES_WITHOUT_RECURSIVE_WORKFLOW_DB_TOOL_SUPPORT.has(
|
||||
registeredModel.sdkPackage,
|
||||
);
|
||||
|
||||
const allFlatObjects = Object.values(
|
||||
flatObjectMetadataMaps.byUniversalIdentifier,
|
||||
)
|
||||
@@ -84,6 +109,15 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
isWorkflowAgentExecution &&
|
||||
(flatObject.isSystem ||
|
||||
flatObject.universalIdentifier ===
|
||||
WORKSPACE_MEMBER_OBJECT_UNIVERSAL_IDENTIFIER)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const permission = objectPermissions[flatObject.id];
|
||||
|
||||
if (!permission) {
|
||||
@@ -103,24 +137,28 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
|
||||
|
||||
if (permission.canReadObjectRecords) {
|
||||
descriptors.push({
|
||||
name: `find_${snakePlural}`,
|
||||
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateFindToolInputSchema(objectMetadata, restrictedFields),
|
||||
),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
if (!shouldExcludeRecursiveFilterToolsForWorkflowModel) {
|
||||
const findToolInputSchema = z.toJSONSchema(
|
||||
generateFindToolInputSchema(objectMetadata, restrictedFields),
|
||||
);
|
||||
|
||||
descriptors.push({
|
||||
name: `find_${snakePlural}`,
|
||||
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: findToolInputSchema,
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'find',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'find',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'find',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
descriptors.push({
|
||||
name: `find_one_${snakeSingular}`,
|
||||
@@ -146,14 +184,22 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
groupBySchema !== null ||
|
||||
hasGroupByToolInputSchema(objectMetadata, restrictedFields);
|
||||
|
||||
if (hasGroupBySchema) {
|
||||
if (
|
||||
hasGroupBySchema &&
|
||||
!shouldExcludeRecursiveFilterToolsForWorkflowModel
|
||||
) {
|
||||
const groupByToolInputSchema =
|
||||
includeSchemas && groupBySchema
|
||||
? z.toJSONSchema(groupBySchema)
|
||||
: null;
|
||||
|
||||
descriptors.push({
|
||||
name: `group_by_${snakePlural}`,
|
||||
description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas &&
|
||||
groupBySchema && {
|
||||
inputSchema: z.toJSONSchema(groupBySchema),
|
||||
groupByToolInputSchema && {
|
||||
inputSchema: groupByToolInputSchema,
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
@@ -228,27 +274,28 @@ export class DatabaseToolProvider implements ToolProvider {
|
||||
operation: 'update',
|
||||
});
|
||||
|
||||
descriptors.push({
|
||||
name: `update_many_${snakePlural}`,
|
||||
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: z.toJSONSchema(
|
||||
generateUpdateManyRecordInputSchema(
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
),
|
||||
),
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
const updateManyInputSchema = z.toJSONSchema(
|
||||
generateUpdateManyRecordInputSchema(objectMetadata, restrictedFields),
|
||||
);
|
||||
|
||||
if (!shouldExcludeRecursiveFilterToolsForWorkflowModel) {
|
||||
descriptors.push({
|
||||
name: `update_many_${snakePlural}`,
|
||||
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
|
||||
category: ToolCategory.DATABASE_CRUD,
|
||||
...(includeSchemas && {
|
||||
inputSchema: updateManyInputSchema,
|
||||
}),
|
||||
executionRef: {
|
||||
kind: 'database_crud',
|
||||
objectNameSingular: objectMetadata.nameSingular,
|
||||
operation: 'update_many',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'update_many',
|
||||
},
|
||||
objectName: objectMetadata.nameSingular,
|
||||
icon: flatObject.icon ?? undefined,
|
||||
operation: 'update_many',
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (permission.canSoftDeleteObjectRecords) {
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
import { NativeModelToolProvider } from './native-model-tool.provider';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { type AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import {
|
||||
type AiModelRegistryService,
|
||||
type RegisteredAiModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
describe('NativeModelToolProvider', () => {
|
||||
const agent = {
|
||||
id: 'agent-id',
|
||||
modelId: 'xai-model',
|
||||
modelConfiguration: {
|
||||
webSearch: { enabled: true },
|
||||
twitterSearch: { enabled: true },
|
||||
},
|
||||
} as FlatAgentWithRoleId;
|
||||
|
||||
const context = {
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'role-id',
|
||||
rolePermissionConfig: { unionOf: ['role-id'] },
|
||||
agent,
|
||||
} as ToolProviderContext;
|
||||
|
||||
it('asks for native model tools even when external web search is preferred', async () => {
|
||||
const registeredModel = {
|
||||
modelId: 'xai-model',
|
||||
} as RegisteredAiModel;
|
||||
const aiModelConfigService = {
|
||||
getNativeModelTools: jest.fn().mockReturnValue({
|
||||
x_search: { type: 'provider', id: 'xai.x_search', args: {} },
|
||||
}),
|
||||
} as Pick<AiModelConfigService, 'getNativeModelTools'>;
|
||||
const aiModelRegistryService = {
|
||||
resolveModelForAgent: jest.fn().mockResolvedValue(registeredModel),
|
||||
} as Pick<AiModelRegistryService, 'resolveModelForAgent'>;
|
||||
const webSearchService = {
|
||||
shouldUseNativeSearch: jest.fn().mockReturnValue(false),
|
||||
} as Pick<WebSearchService, 'shouldUseNativeSearch'>;
|
||||
|
||||
const provider = new NativeModelToolProvider(
|
||||
aiModelConfigService as AiModelConfigService,
|
||||
aiModelRegistryService as AiModelRegistryService,
|
||||
webSearchService as WebSearchService,
|
||||
);
|
||||
|
||||
const tools = await provider.generateTools(context);
|
||||
|
||||
expect(tools).toEqual({
|
||||
x_search: { type: 'provider', id: 'xai.x_search', args: {} },
|
||||
});
|
||||
expect(aiModelConfigService.getNativeModelTools).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
agent,
|
||||
{ useProviderNativeWebSearch: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -6,10 +6,10 @@ import { isDefined } from 'twenty-shared/utils';
|
||||
import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
// SDK-native tools (anthropic webSearch, etc.) are opaque and not serializable.
|
||||
// This provider keeps generateTools() and is excluded from the descriptor system.
|
||||
@@ -32,9 +32,8 @@ export class NativeModelToolProvider implements NativeToolProvider {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (!this.webSearchService.shouldUseNativeSearch()) {
|
||||
return {};
|
||||
}
|
||||
const useProviderNativeWebSearch =
|
||||
this.webSearchService.shouldUseNativeSearch();
|
||||
|
||||
const registeredModel =
|
||||
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
|
||||
@@ -42,6 +41,7 @@ export class NativeModelToolProvider implements NativeToolProvider {
|
||||
return this.aiModelConfigService.getNativeModelTools(
|
||||
registeredModel,
|
||||
context.agent,
|
||||
{ useProviderNativeWebSearch },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -4,7 +4,10 @@ import { type ToolExecutionOptions, type ToolSet, jsonSchema } from 'ai';
|
||||
|
||||
import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface';
|
||||
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import {
|
||||
type ToolContext,
|
||||
type ToolProviderContext,
|
||||
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type';
|
||||
|
||||
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
|
||||
@@ -12,7 +15,6 @@ import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
|
||||
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
|
||||
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util';
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { type JSONSchema7 } from 'json-schema';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
|
||||
|
||||
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
|
||||
import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
|
||||
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
|
||||
|
||||
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import { type ActorMetadata } from 'twenty-shared/types';
|
||||
|
||||
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/code-execution-stream-emitter.type';
|
||||
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
|
||||
export type ToolContext = {
|
||||
workspaceId: string;
|
||||
roleId: string;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
actorContext?: ActorMetadata;
|
||||
userId?: string;
|
||||
userWorkspaceId?: string;
|
||||
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
|
||||
};
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
jest.mock('ai', () => {
|
||||
const actual = jest.requireActual('ai');
|
||||
|
||||
return {
|
||||
...actual,
|
||||
generateText: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { generateText, type ToolSet } from 'ai';
|
||||
|
||||
import { WEB_SEARCH_TOOL_ID } from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { type WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AgentAsyncExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service';
|
||||
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { type AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import {
|
||||
type AiModelRegistryService,
|
||||
type RegisteredAiModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
describe('AgentAsyncExecutorService', () => {
|
||||
const mockedGenerateText = jest.mocked(generateText);
|
||||
|
||||
const registeredModel = {
|
||||
modelId: 'xai/grok',
|
||||
sdkPackage: '@ai-sdk/xai',
|
||||
model: {} as never,
|
||||
} as RegisteredAiModel;
|
||||
|
||||
const createService = ({
|
||||
roleId,
|
||||
roleScopedTools = {},
|
||||
noRoleFallbackActionTools = {},
|
||||
nativeModelTools = {},
|
||||
}: {
|
||||
roleId?: string;
|
||||
roleScopedTools?: ToolSet;
|
||||
noRoleFallbackActionTools?: ToolSet;
|
||||
nativeModelTools?: ToolSet;
|
||||
}) => {
|
||||
const aiModelRegistryService = {
|
||||
validateModelAvailability: jest.fn(),
|
||||
resolveModelForAgent: jest.fn().mockResolvedValue(registeredModel),
|
||||
} as unknown as jest.Mocked<AiModelRegistryService>;
|
||||
|
||||
const aiModelConfigService = {
|
||||
getProviderOptions: jest.fn().mockReturnValue({}),
|
||||
getNativeModelTools: jest.fn().mockReturnValue(nativeModelTools),
|
||||
} as unknown as jest.Mocked<AiModelConfigService>;
|
||||
|
||||
const toolRegistry = {
|
||||
getToolsByCategories: jest
|
||||
.fn()
|
||||
.mockImplementation((_context, options) => {
|
||||
if (
|
||||
options.categories?.includes(ToolCategory.DATABASE_CRUD) &&
|
||||
options.categories?.includes(ToolCategory.ACTION)
|
||||
) {
|
||||
return Promise.resolve(roleScopedTools);
|
||||
}
|
||||
|
||||
if (
|
||||
options.categories?.length === 1 &&
|
||||
options.categories[0] === ToolCategory.ACTION
|
||||
) {
|
||||
return Promise.resolve(noRoleFallbackActionTools);
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
}),
|
||||
} as unknown as jest.Mocked<ToolRegistryService>;
|
||||
|
||||
const webSearchService = {
|
||||
shouldUseNativeSearch: jest.fn().mockReturnValue(true),
|
||||
} as unknown as jest.Mocked<WebSearchService>;
|
||||
|
||||
const roleTargetRepository = {
|
||||
findOne: jest.fn().mockResolvedValue(roleId ? { roleId } : null),
|
||||
};
|
||||
|
||||
const workspaceRepository = {
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity),
|
||||
};
|
||||
|
||||
const service = new AgentAsyncExecutorService(
|
||||
aiModelRegistryService,
|
||||
aiModelConfigService,
|
||||
toolRegistry,
|
||||
webSearchService,
|
||||
roleTargetRepository as never,
|
||||
workspaceRepository as never,
|
||||
);
|
||||
|
||||
return {
|
||||
service,
|
||||
aiModelConfigService,
|
||||
toolRegistry,
|
||||
};
|
||||
};
|
||||
|
||||
const agent = {
|
||||
id: 'agent-id',
|
||||
workspaceId: 'workspace-id',
|
||||
modelId: 'xai/grok',
|
||||
prompt: 'Be helpful.',
|
||||
modelConfiguration: {
|
||||
webSearch: { enabled: true },
|
||||
twitterSearch: { enabled: true },
|
||||
},
|
||||
responseFormat: { type: 'text' },
|
||||
} as unknown as AgentEntity;
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGenerateText.mockReset();
|
||||
mockedGenerateText.mockResolvedValue({
|
||||
text: 'Done',
|
||||
steps: [],
|
||||
usage: {} as never,
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('keeps native and fallback tools available when the agent has no explicit role', async () => {
|
||||
const noRoleFallbackActionTools = {
|
||||
web_search: {
|
||||
description: 'Search the web',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
code_interpreter: {
|
||||
description: 'Run code',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const nativeModelTools = {
|
||||
x_search: {
|
||||
description: 'Search X',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const { service, aiModelConfigService, toolRegistry } = createService({
|
||||
roleId: undefined,
|
||||
noRoleFallbackActionTools,
|
||||
nativeModelTools,
|
||||
});
|
||||
|
||||
await service.executeAgent({
|
||||
agent,
|
||||
userPrompt: 'Find a record.',
|
||||
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
|
||||
});
|
||||
|
||||
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'workflow-role-id',
|
||||
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
|
||||
executionScope: 'workflow_agent',
|
||||
agent: {
|
||||
modelId: 'xai/grok',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
categories: [ToolCategory.ACTION],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
expect(aiModelConfigService.getNativeModelTools).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
{
|
||||
modelId: 'xai/grok',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
{ useProviderNativeWebSearch: true },
|
||||
);
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent.prompt}`,
|
||||
tools: {
|
||||
...noRoleFallbackActionTools,
|
||||
...nativeModelTools,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a workflow system prompt that guards against unverified claims', async () => {
|
||||
const { service } = createService({
|
||||
roleId: undefined,
|
||||
});
|
||||
|
||||
await service.executeAgent({
|
||||
agent,
|
||||
userPrompt: 'Find the latest news.',
|
||||
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
|
||||
});
|
||||
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: expect.stringContaining(
|
||||
'If recent or external information cannot be verified with the available tools, say so',
|
||||
),
|
||||
}),
|
||||
);
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: expect.stringContaining(
|
||||
'Never invent search results, news, X posts, URLs, handles, or record data',
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('intersects the saved agent role with workflow execution permissions', async () => {
|
||||
const roleScopedTools = {
|
||||
find_companies: {
|
||||
description: 'Find companies',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const nativeModelTools = {
|
||||
x_search: {
|
||||
description: 'Search X',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const { service, aiModelConfigService, toolRegistry } = createService({
|
||||
roleId: 'agent-role-id',
|
||||
roleScopedTools,
|
||||
nativeModelTools,
|
||||
});
|
||||
|
||||
await service.executeAgent({
|
||||
agent,
|
||||
userPrompt: 'Find a record.',
|
||||
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
|
||||
});
|
||||
|
||||
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'agent-role-id',
|
||||
rolePermissionConfig: {
|
||||
intersectionOf: ['agent-role-id', 'workflow-role-id'],
|
||||
},
|
||||
executionScope: 'workflow_agent',
|
||||
agent: {
|
||||
modelId: 'xai/grok',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
}),
|
||||
{
|
||||
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledTimes(1);
|
||||
expect(aiModelConfigService.getNativeModelTools).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
{
|
||||
modelId: 'xai/grok',
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
},
|
||||
{ useProviderNativeWebSearch: true },
|
||||
);
|
||||
expect(aiModelConfigService.getProviderOptions).toHaveBeenCalledWith(
|
||||
registeredModel,
|
||||
);
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: {
|
||||
...roleScopedTools,
|
||||
...nativeModelTools,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not load external web search when native web search is already available', async () => {
|
||||
const noRoleFallbackActionTools = {
|
||||
web_search: {
|
||||
description: 'Search the web',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const nativeModelTools = {
|
||||
[WEB_SEARCH_TOOL_ID]: {
|
||||
description: 'Native search the web',
|
||||
inputSchema: {},
|
||||
execute: jest.fn(),
|
||||
},
|
||||
} as unknown as ToolSet;
|
||||
const { service, toolRegistry } = createService({
|
||||
roleId: undefined,
|
||||
noRoleFallbackActionTools,
|
||||
nativeModelTools,
|
||||
});
|
||||
|
||||
await service.executeAgent({
|
||||
agent,
|
||||
userPrompt: 'Find a record.',
|
||||
rolePermissionConfig: { unionOf: ['workflow-role-id'] },
|
||||
});
|
||||
|
||||
expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
workspaceId: 'workspace-id',
|
||||
roleId: 'workflow-role-id',
|
||||
}),
|
||||
{
|
||||
categories: [ToolCategory.ACTION],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
expect(mockedGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: nativeModelTools,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+136
-55
@@ -12,30 +12,50 @@ import { type ActorMetadata } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
|
||||
|
||||
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
|
||||
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { WEB_SEARCH_TOOL_ID } from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
|
||||
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util';
|
||||
import {
|
||||
AiException,
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
|
||||
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
|
||||
import { countNativeXSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-x-search-calls-from-steps.util';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { mergeLanguageModelUsage } from 'src/engine/metadata-modules/ai/ai-billing/utils/merge-language-model-usage.util';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import {
|
||||
AiException,
|
||||
AiExceptionCode,
|
||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
||||
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
|
||||
type EffectiveAgentPermissions = {
|
||||
agentRoleId: string;
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
};
|
||||
|
||||
const toToolProviderAgent = (agent: AgentEntity): ToolProviderAgent => ({
|
||||
modelId: agent.modelId,
|
||||
modelConfiguration: agent.modelConfiguration,
|
||||
});
|
||||
|
||||
const WORKFLOW_NO_ROLE_FALLBACK_TOOL_NAMES = [
|
||||
'code_interpreter',
|
||||
WEB_SEARCH_TOOL_ID,
|
||||
] as const;
|
||||
const WORKFLOW_NO_ROLE_FALLBACK_TOOL_NAMES_SET: ReadonlySet<string> = new Set(
|
||||
WORKFLOW_NO_ROLE_FALLBACK_TOOL_NAMES,
|
||||
);
|
||||
|
||||
// Agent execution within workflows uses database and action tools only.
|
||||
// Workflow tools are intentionally excluded to avoid circular dependencies
|
||||
@@ -48,6 +68,7 @@ export class AgentAsyncExecutorService {
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly aiModelConfigService: AiModelConfigService,
|
||||
private readonly toolRegistry: ToolRegistryService,
|
||||
private readonly webSearchService: WebSearchService,
|
||||
@InjectRepository(RoleTargetEntity)
|
||||
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
|
||||
@InjectRepository(WorkspaceEntity)
|
||||
@@ -76,7 +97,7 @@ export class AgentAsyncExecutorService {
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
rolePermissionConfig?: RolePermissionConfig,
|
||||
): Promise<RolePermissionConfig | undefined> {
|
||||
): Promise<EffectiveAgentPermissions | undefined> {
|
||||
const roleTarget = await this.roleTargetRepository.findOne({
|
||||
where: {
|
||||
agentId,
|
||||
@@ -86,17 +107,19 @@ export class AgentAsyncExecutorService {
|
||||
});
|
||||
|
||||
const agentRoleId = roleTarget?.roleId;
|
||||
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
|
||||
|
||||
const allRoleIds = agentRoleId
|
||||
? [...new Set([...configRoleIds, agentRoleId])]
|
||||
: configRoleIds;
|
||||
|
||||
if (allRoleIds.length === 0) {
|
||||
if (!agentRoleId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { intersectionOf: allRoleIds };
|
||||
const workflowRoleIds = this.extractRoleIds(rolePermissionConfig);
|
||||
|
||||
return {
|
||||
agentRoleId,
|
||||
rolePermissionConfig: {
|
||||
intersectionOf: [...new Set([agentRoleId, ...workflowRoleIds])],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async executeAgent({
|
||||
@@ -112,6 +135,9 @@ export class AgentAsyncExecutorService {
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
authContext?: WorkspaceAuthContext;
|
||||
}): Promise<AgentExecutionResult> {
|
||||
let generatedToolCount = 0;
|
||||
let effectiveAgentPermissions: EffectiveAgentPermissions | undefined;
|
||||
|
||||
try {
|
||||
if (agent) {
|
||||
const workspace = await this.workspaceRepository.findOneBy({
|
||||
@@ -133,52 +159,101 @@ export class AgentAsyncExecutorService {
|
||||
let providerOptions = {};
|
||||
|
||||
if (agent) {
|
||||
const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig(
|
||||
const toolProviderAgent = toToolProviderAgent(agent);
|
||||
const workflowRoleIds = this.extractRoleIds(rolePermissionConfig);
|
||||
const workflowRoleId = workflowRoleIds[0];
|
||||
const workflowToolContextBase = {
|
||||
workspaceId: agent.workspaceId,
|
||||
executionScope: 'workflow_agent' as const,
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: toolProviderAgent,
|
||||
userId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.user.id
|
||||
: undefined,
|
||||
userWorkspaceId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
};
|
||||
|
||||
effectiveAgentPermissions = await this.getEffectiveRolePermissionConfig(
|
||||
agent.id,
|
||||
agent.workspaceId,
|
||||
rolePermissionConfig,
|
||||
);
|
||||
|
||||
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
|
||||
// Workflow tools are excluded to prevent circular dependencies
|
||||
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
|
||||
let roleScopedTools: ToolSet = {};
|
||||
|
||||
tools = await this.toolRegistry.getToolsByCategories(
|
||||
{
|
||||
workspaceId: agent.workspaceId,
|
||||
roleId,
|
||||
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
|
||||
authContext,
|
||||
actorContext,
|
||||
agent: agent as unknown as ToolProviderContext['agent'],
|
||||
userId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.user.id
|
||||
: undefined,
|
||||
userWorkspaceId:
|
||||
isDefined(authContext) && isUserAuthContext(authContext)
|
||||
? authContext.userWorkspaceId
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
categories: [
|
||||
ToolCategory.DATABASE_CRUD,
|
||||
ToolCategory.ACTION,
|
||||
ToolCategory.NATIVE_MODEL,
|
||||
],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
if (effectiveAgentPermissions) {
|
||||
roleScopedTools = await this.toolRegistry.getToolsByCategories(
|
||||
{
|
||||
...workflowToolContextBase,
|
||||
roleId: effectiveAgentPermissions.agentRoleId,
|
||||
rolePermissionConfig:
|
||||
effectiveAgentPermissions.rolePermissionConfig,
|
||||
},
|
||||
{
|
||||
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
providerOptions = this.aiModelConfigService.getProviderOptions(
|
||||
const nativeModelTools = this.aiModelConfigService.getNativeModelTools(
|
||||
registeredModel,
|
||||
agent as unknown as Parameters<
|
||||
typeof this.aiModelConfigService.getProviderOptions
|
||||
>[1],
|
||||
toolProviderAgent,
|
||||
{
|
||||
useProviderNativeWebSearch:
|
||||
this.webSearchService.shouldUseNativeSearch(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
|
||||
let noRoleFallbackTools: ToolSet = {};
|
||||
|
||||
if (
|
||||
!effectiveAgentPermissions &&
|
||||
rolePermissionConfig &&
|
||||
workflowRoleId
|
||||
) {
|
||||
// Temporary workflow fallback: until capability-backed tools are
|
||||
// split out of ACTION, keep the workflow capability tools available
|
||||
// for no-role agents without reopening broader action-tool access.
|
||||
// TODO: We need to take capabilities out of action tools and put them in a separate category
|
||||
const actionTools = await this.toolRegistry.getToolsByCategories(
|
||||
{
|
||||
...workflowToolContextBase,
|
||||
roleId: workflowRoleId,
|
||||
rolePermissionConfig,
|
||||
},
|
||||
{
|
||||
categories: [ToolCategory.ACTION],
|
||||
wrapWithErrorContext: false,
|
||||
},
|
||||
);
|
||||
|
||||
noRoleFallbackTools = Object.fromEntries(
|
||||
Object.entries(actionTools).filter(([toolName]) =>
|
||||
WORKFLOW_NO_ROLE_FALLBACK_TOOL_NAMES_SET.has(toolName),
|
||||
),
|
||||
) as ToolSet;
|
||||
}
|
||||
|
||||
// Keep native tools last so provider-native web_search overrides the
|
||||
// external fallback when both share the same tool name.
|
||||
tools = {
|
||||
...roleScopedTools,
|
||||
...noRoleFallbackTools,
|
||||
...nativeModelTools,
|
||||
};
|
||||
|
||||
providerOptions =
|
||||
this.aiModelConfigService.getProviderOptions(registeredModel);
|
||||
generatedToolCount = Object.keys(tools).length;
|
||||
|
||||
this.logger.log(`Generated ${generatedToolCount} tools for agent`);
|
||||
}
|
||||
|
||||
const textResponse = await generateText({
|
||||
system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`,
|
||||
@@ -211,6 +286,9 @@ export class AgentAsyncExecutorService {
|
||||
const nativeWebSearchCallCount = countNativeWebSearchCallsFromSteps(
|
||||
textResponse.steps,
|
||||
);
|
||||
const nativeXSearchCallCount = countNativeXSearchCallsFromSteps(
|
||||
textResponse.steps,
|
||||
);
|
||||
|
||||
const agentSchema =
|
||||
agent?.responseFormat?.type === 'json'
|
||||
@@ -223,6 +301,7 @@ export class AgentAsyncExecutorService {
|
||||
usage: textResponse.usage,
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
nativeXSearchCallCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -253,11 +332,13 @@ export class AgentAsyncExecutorService {
|
||||
),
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
nativeXSearchCallCount,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof AiException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new AiException(
|
||||
error instanceof Error ? error.message : 'Agent execution failed',
|
||||
AiExceptionCode.AGENT_EXECUTION_FAILED,
|
||||
|
||||
+1
@@ -5,4 +5,5 @@ export interface AgentExecutionResult {
|
||||
usage: LanguageModelUsage;
|
||||
cacheCreationTokens: number;
|
||||
nativeWebSearchCallCount: number;
|
||||
nativeXSearchCallCount: number;
|
||||
}
|
||||
|
||||
+7
-1
@@ -17,7 +17,13 @@ Context:
|
||||
- Focus on completing the task efficiently
|
||||
|
||||
Permissions:
|
||||
- Only perform actions your role allows`,
|
||||
- Only perform actions your role allows
|
||||
|
||||
Reliability:
|
||||
- Use only workflow context, prompt input, and verified tool results
|
||||
- If recent or external information cannot be verified with the available tools, say so
|
||||
- Never invent search results, news, X posts, URLs, handles, or record data
|
||||
- Do not claim a search or lookup happened unless a tool ran`,
|
||||
|
||||
// Structured output generation for workflow data passing
|
||||
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
|
||||
|
||||
+31
-4
@@ -86,18 +86,45 @@ export class AiBillingService {
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
if (nativeWebSearchCallCount <= 0) {
|
||||
this.billNativeSearchUsage(
|
||||
nativeWebSearchCallCount,
|
||||
'web search',
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
billNativeXSearchUsage(
|
||||
nativeXSearchCallCount: number,
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
this.billNativeSearchUsage(
|
||||
nativeXSearchCallCount,
|
||||
'X search',
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private billNativeSearchUsage(
|
||||
nativeSearchCallCount: number,
|
||||
searchLabel: string,
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string | null,
|
||||
): void {
|
||||
if (nativeSearchCallCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const costInDollars =
|
||||
nativeWebSearchCallCount * NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS;
|
||||
nativeSearchCallCount * NATIVE_WEB_SEARCH_COST_PER_CALL_DOLLARS;
|
||||
const creditsUsedMicro = Math.round(
|
||||
convertDollarsToBillingCredits(costInDollars),
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Native web search billing: ${nativeWebSearchCallCount} calls, $${costInDollars.toFixed(4)}`,
|
||||
`Native ${searchLabel} billing: ${nativeSearchCallCount} calls, $${costInDollars.toFixed(4)}`,
|
||||
);
|
||||
|
||||
this.workspaceEventEmitter.emitCustomBatchEvent<UsageEvent>(
|
||||
@@ -107,7 +134,7 @@ export class AiBillingService {
|
||||
resourceType: UsageResourceType.AI,
|
||||
operationType: UsageOperationType.WEB_SEARCH,
|
||||
creditsUsedMicro,
|
||||
quantity: nativeWebSearchCallCount,
|
||||
quantity: nativeSearchCallCount,
|
||||
unit: UsageUnit.INVOCATION,
|
||||
userWorkspaceId: userWorkspaceId || null,
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
const WEB_SEARCH_TOOL_NAME = 'web_search';
|
||||
import { WEB_SEARCH_TOOL_ID } from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
|
||||
export const countNativeWebSearchCallsFromSteps = (
|
||||
steps: StepResult<ToolSet>[],
|
||||
@@ -9,7 +9,7 @@ export const countNativeWebSearchCallsFromSteps = (
|
||||
(count, step) =>
|
||||
count +
|
||||
step.toolCalls.filter(
|
||||
(toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME,
|
||||
(toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_ID,
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { type StepResult, type ToolSet } from 'ai';
|
||||
|
||||
import { X_SEARCH_TOOL_ID } from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
|
||||
export const countNativeXSearchCallsFromSteps = (
|
||||
steps: StepResult<ToolSet>[],
|
||||
): number =>
|
||||
steps.reduce(
|
||||
(count, step) =>
|
||||
count +
|
||||
step.toolCalls.filter(
|
||||
(toolCall) => toolCall.toolName === X_SEARCH_TOOL_ID,
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
+46
-69
@@ -23,6 +23,7 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { WEB_SEARCH_TOOL_ID } from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
@@ -39,6 +40,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/
|
||||
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
|
||||
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
|
||||
import { AiBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
|
||||
import { countNativeXSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-x-search-calls-from-steps.util';
|
||||
import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util';
|
||||
import { extractCacheCreationTokensFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/extract-cache-creation-tokens.util';
|
||||
import { MessagePruningService } from 'src/engine/metadata-modules/ai/ai-chat/services/message-pruning.service';
|
||||
@@ -50,15 +52,11 @@ import {
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
AI_SDK_BEDROCK,
|
||||
AI_SDK_OPENAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
type RegisteredAiModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type';
|
||||
import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service';
|
||||
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
|
||||
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
|
||||
|
||||
@@ -93,7 +91,7 @@ export class ChatExecutionService {
|
||||
private readonly codeInterpreterService: CodeInterpreterService,
|
||||
private readonly systemPromptBuilder: SystemPromptBuilderService,
|
||||
private readonly exceptionHandlerService: ExceptionHandlerService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
private readonly aiModelConfigService: AiModelConfigService,
|
||||
private readonly messagePruningService: MessagePruningService,
|
||||
private readonly webSearchService: WebSearchService,
|
||||
) {}
|
||||
@@ -143,16 +141,7 @@ export class ChatExecutionService {
|
||||
);
|
||||
|
||||
const useNativeSearch = this.webSearchService.shouldUseNativeSearch();
|
||||
|
||||
const toolNamesToPreload = [
|
||||
...COMMON_PRELOAD_TOOLS,
|
||||
...(useNativeSearch ? [] : ['web_search']),
|
||||
];
|
||||
|
||||
const preloadedTools = await this.toolRegistry.getToolsByName(
|
||||
toolNamesToPreload,
|
||||
toolContext,
|
||||
);
|
||||
const externalWebSearchEnabled = this.webSearchService.isEnabled();
|
||||
|
||||
const resolvedModelId = modelId ?? workspace.smartModel;
|
||||
|
||||
@@ -170,10 +159,25 @@ export class ChatExecutionService {
|
||||
registeredModel.modelId,
|
||||
);
|
||||
|
||||
const { tools: nativeSearchTools, callableToolNames: searchToolNames } =
|
||||
useNativeSearch
|
||||
? this.getNativeWebSearchTools(registeredModel)
|
||||
: { tools: {}, callableToolNames: [] };
|
||||
const {
|
||||
tools: nativeSearchTools,
|
||||
hasWebSearch: hasNativeWebSearch,
|
||||
hasXSearch: hasNativeXSearch,
|
||||
} = this.aiModelConfigService.getChatNativeSearchPlan(registeredModel, {
|
||||
useProviderNativeWebSearch: useNativeSearch,
|
||||
});
|
||||
|
||||
const toolNamesToPreload = [
|
||||
...COMMON_PRELOAD_TOOLS,
|
||||
...(!hasNativeWebSearch && externalWebSearchEnabled
|
||||
? [WEB_SEARCH_TOOL_ID]
|
||||
: []),
|
||||
];
|
||||
|
||||
const preloadedTools = await this.toolRegistry.getToolsByName(
|
||||
toolNamesToPreload,
|
||||
toolContext,
|
||||
);
|
||||
|
||||
// Direct tools: native provider tools + preloaded tools.
|
||||
// These are callable directly AND as fallback through execute_tool.
|
||||
@@ -184,8 +188,14 @@ export class ChatExecutionService {
|
||||
|
||||
const preloadedToolNames = [
|
||||
...Object.keys(preloadedTools),
|
||||
...searchToolNames,
|
||||
...Object.keys(nativeSearchTools),
|
||||
];
|
||||
const excludedChatToolNames = hasNativeWebSearch
|
||||
? new Set([WEB_SEARCH_TOOL_ID])
|
||||
: undefined;
|
||||
const toolCatalogForPrompt = hasNativeWebSearch
|
||||
? toolCatalog.filter((tool) => tool.name !== WEB_SEARCH_TOOL_ID)
|
||||
: toolCatalog;
|
||||
|
||||
// ToolSet is constant for the entire conversation — no mutation.
|
||||
// learn_tools returns schemas as text; execute_tool dispatches to cached tools.
|
||||
@@ -194,11 +204,13 @@ export class ChatExecutionService {
|
||||
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
excludedChatToolNames,
|
||||
),
|
||||
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
|
||||
this.toolRegistry,
|
||||
toolContext,
|
||||
directTools,
|
||||
excludedChatToolNames,
|
||||
),
|
||||
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool(
|
||||
(skillNames) =>
|
||||
@@ -234,7 +246,7 @@ export class ChatExecutionService {
|
||||
}
|
||||
|
||||
const systemPrompt = this.systemPromptBuilder.buildFullPrompt(
|
||||
toolCatalog,
|
||||
toolCatalogForPrompt,
|
||||
skillCatalog,
|
||||
preloadedToolNames,
|
||||
contextString,
|
||||
@@ -330,7 +342,7 @@ export class ChatExecutionService {
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
if (useNativeSearch) {
|
||||
if (hasNativeWebSearch) {
|
||||
const nativeWebSearchCallCount =
|
||||
countNativeWebSearchCallsFromSteps(steps);
|
||||
|
||||
@@ -340,6 +352,16 @@ export class ChatExecutionService {
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
if (hasNativeXSearch) {
|
||||
const nativeXSearchCallCount = countNativeXSearchCallsFromSteps(steps);
|
||||
|
||||
this.aiBillingService.billNativeXSearchUsage(
|
||||
nativeXSearchCallCount,
|
||||
workspace.id,
|
||||
userWorkspaceId,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const stream = streamText({
|
||||
@@ -455,51 +477,6 @@ export class ChatExecutionService {
|
||||
return context;
|
||||
}
|
||||
|
||||
private getNativeWebSearchTools(model: RegisteredAiModel): {
|
||||
tools: ToolSet;
|
||||
callableToolNames: string[];
|
||||
} {
|
||||
const empty = { tools: {}, callableToolNames: [] };
|
||||
const providerName = model.providerName;
|
||||
|
||||
if (!providerName) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawAnthropicProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch_20250305() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
case AI_SDK_BEDROCK:
|
||||
return empty;
|
||||
case AI_SDK_OPENAI: {
|
||||
const provider =
|
||||
this.sdkProviderFactory.getRawOpenAIProvider(providerName);
|
||||
|
||||
if (!provider) {
|
||||
return empty;
|
||||
}
|
||||
|
||||
return {
|
||||
tools: { web_search: provider.tools.webSearch() },
|
||||
callableToolNames: ['web_search'],
|
||||
};
|
||||
}
|
||||
default:
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async storeExtractedFiles(
|
||||
files: ExtractedFile[],
|
||||
_workspaceId: string,
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
import { AiModelConfigService } from './ai-model-config.service';
|
||||
|
||||
import {
|
||||
WEB_SEARCH_TOOL_ID,
|
||||
X_SEARCH_TOOL_ID,
|
||||
} from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
|
||||
describe('AiModelConfigService', () => {
|
||||
const createService = (
|
||||
sdkProviderFactory: Partial<SdkProviderFactoryService>,
|
||||
) =>
|
||||
new AiModelConfigService(sdkProviderFactory as SdkProviderFactoryService);
|
||||
|
||||
const xSearchTool = { type: 'provider', id: 'xai.x_search', args: {} };
|
||||
const webSearchTool = { type: 'provider', id: 'xai.web_search', args: {} };
|
||||
|
||||
it('keeps x search available for xAI chat when external web search is preferred', () => {
|
||||
const service = createService({
|
||||
getRawXaiProvider: jest.fn().mockReturnValue({
|
||||
tools: {
|
||||
xSearch: jest.fn().mockReturnValue(xSearchTool),
|
||||
webSearch: jest.fn().mockReturnValue(webSearchTool),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = service.getChatNativeSearchPlan(
|
||||
{
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
providerName: 'xai',
|
||||
} as RegisteredAiModel,
|
||||
{ useProviderNativeWebSearch: false },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
tools: {
|
||||
[X_SEARCH_TOOL_ID]: xSearchTool,
|
||||
},
|
||||
hasWebSearch: false,
|
||||
hasXSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes both x search and native web search for xAI chat when enabled', () => {
|
||||
const service = createService({
|
||||
getRawXaiProvider: jest.fn().mockReturnValue({
|
||||
tools: {
|
||||
xSearch: jest.fn().mockReturnValue(xSearchTool),
|
||||
webSearch: jest.fn().mockReturnValue(webSearchTool),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const result = service.getChatNativeSearchPlan(
|
||||
{
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
providerName: 'xai',
|
||||
} as RegisteredAiModel,
|
||||
{ useProviderNativeWebSearch: true },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
tools: {
|
||||
[WEB_SEARCH_TOOL_ID]: webSearchTool,
|
||||
[X_SEARCH_TOOL_ID]: xSearchTool,
|
||||
},
|
||||
hasWebSearch: true,
|
||||
hasXSearch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps OpenAI native web search disabled when external search is preferred', () => {
|
||||
const service = createService({
|
||||
getRawOpenAIProvider: jest.fn(),
|
||||
});
|
||||
|
||||
const result = service.getChatNativeSearchPlan(
|
||||
{
|
||||
sdkPackage: AI_SDK_OPENAI,
|
||||
providerName: 'openai',
|
||||
} as RegisteredAiModel,
|
||||
{ useProviderNativeWebSearch: false },
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
tools: {},
|
||||
hasWebSearch: false,
|
||||
hasXSearch: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
+143
-84
@@ -2,7 +2,13 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ProviderOptions } from '@ai-sdk/provider-utils';
|
||||
import { ToolSet } from 'ai';
|
||||
import { isAgentCapabilityEnabled } from 'twenty-shared/ai';
|
||||
|
||||
import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type';
|
||||
import {
|
||||
WEB_SEARCH_TOOL_ID,
|
||||
X_SEARCH_TOOL_ID,
|
||||
} from 'src/engine/core-modules/tool-provider/constants/search-tool-ids.const';
|
||||
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
|
||||
import {
|
||||
AI_SDK_ANTHROPIC,
|
||||
@@ -10,27 +16,24 @@ import {
|
||||
AI_SDK_OPENAI,
|
||||
AI_SDK_XAI,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const';
|
||||
import {
|
||||
AiModelRegistryService,
|
||||
RegisteredAiModel,
|
||||
} from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
|
||||
import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service';
|
||||
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
|
||||
|
||||
type NativeSearchToolEntry = [string, ToolSet[string]];
|
||||
type ChatNativeSearchPlan = {
|
||||
tools: ToolSet;
|
||||
hasWebSearch: boolean;
|
||||
hasXSearch: boolean;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiModelConfigService {
|
||||
constructor(
|
||||
private readonly aiModelRegistryService: AiModelRegistryService,
|
||||
private readonly sdkProviderFactory: SdkProviderFactoryService,
|
||||
) {}
|
||||
constructor(private readonly sdkProviderFactory: SdkProviderFactoryService) {}
|
||||
|
||||
getProviderOptions(
|
||||
model: RegisteredAiModel,
|
||||
agent: FlatAgentWithRoleId,
|
||||
): ProviderOptions {
|
||||
getProviderOptions(model: RegisteredAiModel): ProviderOptions {
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_XAI:
|
||||
return this.getXaiProviderOptions(agent);
|
||||
return {};
|
||||
case AI_SDK_ANTHROPIC:
|
||||
return this.getAnthropicProviderOptions(model);
|
||||
case AI_SDK_BEDROCK:
|
||||
@@ -42,83 +45,44 @@ export class AiModelConfigService {
|
||||
|
||||
getNativeModelTools(
|
||||
model: RegisteredAiModel,
|
||||
agent: FlatAgentWithRoleId,
|
||||
agent: ToolProviderAgent,
|
||||
options: { useProviderNativeWebSearch: boolean },
|
||||
): ToolSet {
|
||||
const tools: ToolSet = {};
|
||||
const modelConfiguration = agent.modelConfiguration ?? {};
|
||||
const isWebSearchEnabledForAgent = isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'webSearch',
|
||||
);
|
||||
const isTwitterSearchEnabledForAgent = isAgentCapabilityEnabled(
|
||||
modelConfiguration,
|
||||
'twitterSearch',
|
||||
);
|
||||
const shouldExposeProviderNativeWebSearch =
|
||||
options.useProviderNativeWebSearch && isWebSearchEnabledForAgent;
|
||||
|
||||
if (!agent.modelConfiguration) {
|
||||
return tools;
|
||||
}
|
||||
const toolEntries = this.getNativeSearchToolEntries(model, {
|
||||
exposeWebSearch: shouldExposeProviderNativeWebSearch,
|
||||
exposeTwitterSearch: isTwitterSearchEnabledForAgent,
|
||||
});
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
const anthropicProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawAnthropicProvider(
|
||||
model.providerName,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (anthropicProvider) {
|
||||
tools.web_search = anthropicProvider.tools.webSearch_20250305();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AI_SDK_BEDROCK: {
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
const bedrockProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawBedrockProvider(model.providerName)
|
||||
: undefined;
|
||||
|
||||
if (bedrockProvider) {
|
||||
tools.web_search =
|
||||
bedrockProvider.tools.webSearch_20250305() as ToolSet[string];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AI_SDK_OPENAI:
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
const openaiProvider = model.providerName
|
||||
? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName)
|
||||
: undefined;
|
||||
|
||||
if (openaiProvider) {
|
||||
tools.web_search = openaiProvider.tools.webSearch();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return tools;
|
||||
return Object.fromEntries(toolEntries) as ToolSet;
|
||||
}
|
||||
|
||||
private getXaiProviderOptions(agent: FlatAgentWithRoleId): ProviderOptions {
|
||||
if (
|
||||
!agent.modelConfiguration ||
|
||||
(!agent.modelConfiguration.webSearch?.enabled &&
|
||||
!agent.modelConfiguration.twitterSearch?.enabled)
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const sources: Array<{ type: string }> = [];
|
||||
|
||||
if (agent.modelConfiguration.webSearch?.enabled) {
|
||||
sources.push({ type: 'web' });
|
||||
}
|
||||
|
||||
if (agent.modelConfiguration.twitterSearch?.enabled) {
|
||||
sources.push({ type: 'x' });
|
||||
}
|
||||
getChatNativeSearchPlan(
|
||||
model: RegisteredAiModel,
|
||||
options: { useProviderNativeWebSearch: boolean },
|
||||
): ChatNativeSearchPlan {
|
||||
const toolEntries = this.getNativeSearchToolEntries(model, {
|
||||
exposeWebSearch: options.useProviderNativeWebSearch,
|
||||
exposeTwitterSearch: model.sdkPackage === AI_SDK_XAI,
|
||||
});
|
||||
|
||||
return {
|
||||
xai: {
|
||||
searchParameters: {
|
||||
mode: 'auto',
|
||||
...(sources.length > 0 && { sources }),
|
||||
},
|
||||
},
|
||||
tools: Object.fromEntries(toolEntries) as ToolSet,
|
||||
hasWebSearch: toolEntries.some(
|
||||
([toolId]) => toolId === WEB_SEARCH_TOOL_ID,
|
||||
),
|
||||
hasXSearch: toolEntries.some(([toolId]) => toolId === X_SEARCH_TOOL_ID),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,4 +117,99 @@ export class AiModelConfigService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getNativeSearchToolEntries(
|
||||
model: RegisteredAiModel,
|
||||
options: {
|
||||
exposeWebSearch: boolean;
|
||||
exposeTwitterSearch: boolean;
|
||||
},
|
||||
): NativeSearchToolEntry[] {
|
||||
if (!model.providerName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (model.sdkPackage) {
|
||||
case AI_SDK_ANTHROPIC: {
|
||||
if (!options.exposeWebSearch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const anthropicProvider =
|
||||
this.sdkProviderFactory.getRawAnthropicProvider(model.providerName);
|
||||
|
||||
if (!anthropicProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[WEB_SEARCH_TOOL_ID, anthropicProvider.tools.webSearch_20250305()],
|
||||
];
|
||||
}
|
||||
case AI_SDK_BEDROCK: {
|
||||
if (!options.exposeWebSearch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const bedrockProvider = this.sdkProviderFactory.getRawBedrockProvider(
|
||||
model.providerName,
|
||||
);
|
||||
|
||||
if (!bedrockProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
WEB_SEARCH_TOOL_ID,
|
||||
bedrockProvider.tools.webSearch_20250305() as ToolSet[string],
|
||||
],
|
||||
];
|
||||
}
|
||||
case AI_SDK_OPENAI: {
|
||||
if (!options.exposeWebSearch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const openAiProvider = this.sdkProviderFactory.getRawOpenAIProvider(
|
||||
model.providerName,
|
||||
);
|
||||
|
||||
if (!openAiProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [[WEB_SEARCH_TOOL_ID, openAiProvider.tools.webSearch()]];
|
||||
}
|
||||
case AI_SDK_XAI: {
|
||||
const xaiProvider = this.sdkProviderFactory.getRawXaiProvider(
|
||||
model.providerName,
|
||||
);
|
||||
|
||||
if (!xaiProvider) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const toolEntries: NativeSearchToolEntry[] = [];
|
||||
|
||||
if (options.exposeWebSearch) {
|
||||
toolEntries.push([
|
||||
WEB_SEARCH_TOOL_ID,
|
||||
xaiProvider.tools.webSearch() as ToolSet[string],
|
||||
]);
|
||||
}
|
||||
|
||||
if (options.exposeTwitterSearch) {
|
||||
toolEntries.push([
|
||||
X_SEARCH_TOOL_ID,
|
||||
xaiProvider.tools.xSearch() as ToolSet[string],
|
||||
]);
|
||||
}
|
||||
|
||||
return toolEntries;
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+19
-2
@@ -9,7 +9,7 @@ import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createMistral } from '@ai-sdk/mistral';
|
||||
import { createOpenAI, type OpenAIProvider } from '@ai-sdk/openai';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import { createXai } from '@ai-sdk/xai';
|
||||
import { createXai, type XaiProvider } from '@ai-sdk/xai';
|
||||
import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
|
||||
import { type LanguageModel } from 'ai';
|
||||
import { type AiSdkPackage } from 'twenty-shared/ai';
|
||||
@@ -85,6 +85,10 @@ export class SdkProviderFactoryService {
|
||||
return this.getRawProvider<OpenAIProvider>(providerName, AI_SDK_OPENAI);
|
||||
}
|
||||
|
||||
getRawXaiProvider(providerName: string): XaiProvider | undefined {
|
||||
return this.getRawProvider<XaiProvider>(providerName, AI_SDK_XAI);
|
||||
}
|
||||
|
||||
clearCache(): void {
|
||||
this.providerInstances.clear();
|
||||
}
|
||||
@@ -102,7 +106,7 @@ export class SdkProviderFactoryService {
|
||||
case AI_SDK_MISTRAL:
|
||||
return this.buildStandardProvider(config, createMistral);
|
||||
case AI_SDK_XAI:
|
||||
return this.buildStandardProvider(config, createXai);
|
||||
return this.buildXaiProvider(config);
|
||||
case AI_SDK_BEDROCK:
|
||||
return this.buildBedrockProvider(config);
|
||||
case AI_SDK_OPENAI_COMPATIBLE:
|
||||
@@ -186,4 +190,17 @@ export class SdkProviderFactoryService {
|
||||
sdkPackage: AI_SDK_OPENAI_COMPATIBLE,
|
||||
};
|
||||
}
|
||||
|
||||
private buildXaiProvider(config: AiProviderConfig): AiSdkProviderInstance {
|
||||
const provider = createXai({
|
||||
...(config.apiKey && { apiKey: config.apiKey }),
|
||||
...(config.baseUrl && { baseURL: config.baseUrl }),
|
||||
});
|
||||
|
||||
return {
|
||||
createModel: (modelId: string) => provider.responses(modelId),
|
||||
rawProvider: provider,
|
||||
sdkPackage: AI_SDK_XAI,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+21
-10
@@ -81,16 +81,21 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
? executionContext.authContext.userWorkspaceId
|
||||
: null;
|
||||
|
||||
const { result, usage, cacheCreationTokens, nativeWebSearchCallCount } =
|
||||
await this.aiAgentExecutionService.executeAgent({
|
||||
agent,
|
||||
userPrompt: resolveInput(prompt, context) as string,
|
||||
actorContext: executionContext.isActingOnBehalfOfUser
|
||||
? executionContext.initiator
|
||||
: undefined,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
authContext: executionContext.authContext,
|
||||
});
|
||||
const {
|
||||
result,
|
||||
usage,
|
||||
cacheCreationTokens,
|
||||
nativeWebSearchCallCount,
|
||||
nativeXSearchCallCount,
|
||||
} = await this.aiAgentExecutionService.executeAgent({
|
||||
agent,
|
||||
userPrompt: resolveInput(prompt, context) as string,
|
||||
actorContext: executionContext.isActingOnBehalfOfUser
|
||||
? executionContext.initiator
|
||||
: undefined,
|
||||
rolePermissionConfig: executionContext.rolePermissionConfig,
|
||||
authContext: executionContext.authContext,
|
||||
});
|
||||
|
||||
await this.aiBillingService.calculateAndBillUsage(
|
||||
agent?.modelId ?? AUTO_SELECT_SMART_MODEL_ID,
|
||||
@@ -109,6 +114,12 @@ export class AiAgentWorkflowAction implements WorkflowAction {
|
||||
);
|
||||
}
|
||||
|
||||
this.aiBillingService.billNativeXSearchUsage(
|
||||
nativeXSearchCallCount,
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
result,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { type AgentCapability } from '../types/agent-capability.type';
|
||||
|
||||
export const AGENT_CAPABILITY_DEFAULTS = {
|
||||
webSearch: true,
|
||||
twitterSearch: false,
|
||||
codeInterpreter: false,
|
||||
} satisfies Record<AgentCapability, boolean>;
|
||||
@@ -7,6 +7,7 @@
|
||||
* |___/
|
||||
*/
|
||||
|
||||
export { AGENT_CAPABILITY_DEFAULTS } from './constants/agent-capability-defaults.const';
|
||||
export { AI_SDK_PACKAGE_LABELS } from './constants/ai-sdk-package-labels.const';
|
||||
export type { AiSdkPackage } from './constants/ai-sdk-packages.const';
|
||||
export { AI_SDK_PACKAGES } from './constants/ai-sdk-packages.const';
|
||||
@@ -15,6 +16,7 @@ export { DATA_RESIDENCY_KEYS } from './constants/data-residency.const';
|
||||
export type { NativeAiSdkProviderId } from './constants/native-ai-sdk-provider-ids.const';
|
||||
export { NATIVE_AI_SDK_PROVIDER_IDS } from './constants/native-ai-sdk-provider-ids.const';
|
||||
export { ToolCategory } from './constants/tool-category.const';
|
||||
export type { AgentCapability } from './types/agent-capability.type';
|
||||
export type {
|
||||
AgentResponseFieldType,
|
||||
AgentResponseSchema,
|
||||
@@ -37,5 +39,6 @@ export type { ExtendedUIMessagePart } from './types/ExtendedUIMessagePart';
|
||||
export type { ModelConfiguration } from './types/model-configuration.type';
|
||||
export type { NavigateAppToolOutput } from './types/NavigateAppToolOutput';
|
||||
export { inferAiSdkPackage } from './utils/infer-ai-sdk-package.util';
|
||||
export { isAgentCapabilityEnabled } from './utils/is-agent-capability-enabled.util';
|
||||
export { isAiSdkPackage } from './utils/is-ai-sdk-package.util';
|
||||
export { isDataResidency } from './utils/is-data-residency.util';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export type AgentCapability = 'webSearch' | 'twitterSearch' | 'codeInterpreter';
|
||||
@@ -7,4 +7,8 @@ export type ModelConfiguration = {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
codeInterpreter?: {
|
||||
enabled: boolean;
|
||||
configuration?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../../constants/agent-capability-defaults.const';
|
||||
import { isAgentCapabilityEnabled } from '../is-agent-capability-enabled.util';
|
||||
|
||||
describe('isAgentCapabilityEnabled', () => {
|
||||
it('returns the shared defaults when an agent does not override a capability', () => {
|
||||
expect(isAgentCapabilityEnabled(undefined, 'webSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.webSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'twitterSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.twitterSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'codeInterpreter')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.codeInterpreter,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit agent configuration over the shared defaults', () => {
|
||||
const modelConfiguration = {
|
||||
webSearch: { enabled: false },
|
||||
twitterSearch: { enabled: true },
|
||||
codeInterpreter: { enabled: false },
|
||||
};
|
||||
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'webSearch')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'twitterSearch')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isAgentCapabilityEnabled(modelConfiguration, 'codeInterpreter'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../constants/agent-capability-defaults.const';
|
||||
import { type AgentCapability } from '../types/agent-capability.type';
|
||||
import { type ModelConfiguration } from '../types/model-configuration.type';
|
||||
|
||||
export const isAgentCapabilityEnabled = (
|
||||
modelConfiguration: ModelConfiguration | null | undefined,
|
||||
capability: AgentCapability,
|
||||
): boolean => {
|
||||
return (
|
||||
modelConfiguration?.[capability]?.enabled ??
|
||||
AGENT_CAPABILITY_DEFAULTS[capability]
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user