Compare commits

...
46 Commits
Author SHA1 Message Date
ehconitin f9f21602aa more 2026-04-20 23:54:53 +05:30
ehconitin 3ca83f3a46 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-20 22:20:13 +05:30
ehconitin 488886aed2 fix select 2026-04-20 21:56:47 +05:30
ehconitin 3709a0ceed ci 2026-04-20 21:40:03 +05:30
ehconitin 04847b981a lint 2026-04-20 21:26:49 +05:30
ehconitin 55acadcef3 ugly 2026-04-20 21:25:47 +05:30
ehconitin 7c72a2b4e4 x seach billing 2026-04-20 20:08:19 +05:30
ehconitin 7440a7c39b lint 2026-04-20 19:59:11 +05:30
ehconitin fe40537213 codegen 2026-04-20 19:51:14 +05:30
ehconitin 3549d6f30b Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-20 19:48:35 +05:30
ehconitin 39d4019349 more 2026-04-20 19:47:41 +05:30
ehconitin bc0e648380 cleanup 2026-04-20 19:42:34 +05:30
ehconitin 81a4d0c5ad simplify workflow agent tool loading 2026-04-20 18:29:31 +05:30
ehconitin 49111748ca more 2026-04-20 17:13:09 +05:30
ehconitin b99d77b81e thread model sdk package into tool provider context 2026-04-20 17:12:09 +05:30
ehconitin 27fd89e1f2 dedupe update_many input schema 2026-04-20 17:09:53 +05:30
ehconitin 1c2ae1cdf6 no tools for workflow agents without a role 2026-04-20 17:08:34 +05:30
ehconitin adad39accb continue 2026-04-20 14:59:19 +05:30
ehconitin 83b3a4b7dc conflicts 2026-04-20 12:03:17 +05:30
ehconitin 23071bd2d6 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-20 12:03:07 +05:30
ehconitin 15a962b196 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-17 23:09:01 +05:30
ehconitin cc64bdb920 more 2026-04-17 01:30:10 +05:30
ehconitin 62e6bddb64 more 2026-04-17 00:53:10 +05:30
ehconitin 8f7fdf4d7e more 2026-04-17 00:41:59 +05:30
ehconitin 0e3f502d4a Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 23:42:44 +05:30
ehconitin 99c6e88226 more 2026-04-16 23:42:19 +05:30
ehconitin 1025e24a23 more 2026-04-16 23:20:19 +05:30
ehconitin d570020a9b more 2026-04-16 22:32:06 +05:30
ehconitin 06bdb65d37 more 2026-04-16 22:31:07 +05:30
ehconitin 42194646eb fix 2026-04-16 19:36:32 +05:30
ehconitin 32ac1a93fe Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 19:07:03 +05:30
ehconitin ed9e187438 more 2026-04-16 18:57:14 +05:30
ehconitin 78fd9a5efe more 2026-04-16 18:11:19 +05:30
ehconitin a86cab7594 more 2026-04-16 17:18:56 +05:30
ehconitin cd914a358d more 2026-04-16 16:27:42 +05:30
ehconitin 49a7769836 codegen 2026-04-16 13:52:11 +05:30
ehconitin be5799212c Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-16 13:43:29 +05:30
ehconitin a82b13df2d more 2026-04-16 13:42:37 +05:30
ehconitin 70112bf48f Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-13 12:52:56 +05:30
ehconitin a7ff1cbb25 Merge remote-tracking branch 'upstream/main' into ai-fast-follows-16 2026-04-13 11:53:31 +05:30
ehconitin 3eb14f6861 more 2026-04-13 11:45:00 +05:30
ehconitin 7a31f2dad2 more 2026-04-10 20:34:28 +05:30
ehconitin eea99df480 Add per-agent code interpreter toggle 2026-04-10 16:14:07 +05:30
ehconitin 4fcc389c11 Rename nativeCapabilities to capabilities with AgentCapabilities type 2026-04-10 16:07:25 +05:30
ehconitin 5691c9808d Expose web search toggle for EXA-configured models and rename capabilities section 2026-04-10 15:55:37 +05:30
ehconitin db7ab32987 done 2026-04-10 14:24:33 +05:30
46 changed files with 1597 additions and 447 deletions
@@ -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']>;
@@ -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,
@@ -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;
@@ -38,6 +38,7 @@ const mockClientConfig = {
mutationMaximumAffectedRecords: 100,
},
isAttachmentPreviewEnabled: true,
isCodeInterpreterEnabled: false,
analyticsEnabled: false,
canManageFeatureFlags: true,
publicFeatureFlags: [],
@@ -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}
@@ -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,
@@ -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: [],
@@ -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;
@@ -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');
});
});
});
@@ -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') ===
@@ -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';
@@ -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'
>;
@@ -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'
>;
@@ -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),
);
@@ -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',
]),
);
});
});
@@ -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) {
@@ -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 },
);
});
});
@@ -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,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';
@@ -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,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';
@@ -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;
};
@@ -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,
}),
);
});
});
@@ -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,
@@ -5,4 +5,5 @@ export interface AgentExecutionResult {
usage: LanguageModelUsage;
cacheCreationTokens: number;
nativeWebSearchCallCount: number;
nativeXSearchCallCount: number;
}
@@ -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.
@@ -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,
},
@@ -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,
);
@@ -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,
);
@@ -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,
@@ -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,
});
});
});
@@ -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 [];
}
}
}
@@ -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,
};
}
}
@@ -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>;
+3
View File
@@ -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>;
};
};
@@ -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]
);
};