diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 51357f175de..f813a741aab 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -1735,7 +1735,6 @@ type PublicWorkspaceData { type AgentCapabilities { webSearch: Boolean twitterSearch: Boolean - codeInterpreter: Boolean } type ClientAIModelConfig { @@ -1856,6 +1855,7 @@ type ClientConfig { analyticsEnabled: Boolean! support: Support! isAttachmentPreviewEnabled: Boolean! + isCodeInterpreterEnabled: Boolean! sentry: Sentry! captcha: Captcha! api: ApiConfig! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 72516fbe162..92ab179013a 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -1433,7 +1433,6 @@ export interface PublicWorkspaceData { export interface AgentCapabilities { webSearch?: Scalars['Boolean'] twitterSearch?: Scalars['Boolean'] - codeInterpreter?: Scalars['Boolean'] __typename: 'AgentCapabilities' } @@ -1554,6 +1553,7 @@ export interface ClientConfig { analyticsEnabled: Scalars['Boolean'] support: Support isAttachmentPreviewEnabled: Scalars['Boolean'] + isCodeInterpreterEnabled: Scalars['Boolean'] sentry: Sentry captcha: Captcha api: ApiConfig @@ -4684,7 +4684,6 @@ export interface PublicWorkspaceDataGenqlSelection{ export interface AgentCapabilitiesGenqlSelection{ webSearch?: boolean | number twitterSearch?: boolean | number - codeInterpreter?: boolean | number __typename?: boolean | number __scalar?: boolean | number } @@ -4811,6 +4810,7 @@ export interface ClientConfigGenqlSelection{ analyticsEnabled?: boolean | number support?: SupportGenqlSelection isAttachmentPreviewEnabled?: boolean | number + isCodeInterpreterEnabled?: boolean | number sentry?: SentryGenqlSelection captcha?: CaptchaGenqlSelection api?: ApiConfigGenqlSelection diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index b9953dafe1f..04bab4e8d84 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -3485,9 +3485,6 @@ export default { "twitterSearch": [ 6 ], - "codeInterpreter": [ - 6 - ], "__typename": [ 1 ] @@ -3749,6 +3746,9 @@ export default { "isAttachmentPreviewEnabled": [ 6 ], + "isCodeInterpreterEnabled": [ + 6 + ], "sentry": [ 185 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 24e3e437ec3..996ee98c872 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -165,7 +165,6 @@ export type Agent = { export type AgentCapabilities = { __typename?: 'AgentCapabilities'; - codeInterpreter?: Maybe; twitterSearch?: Maybe; webSearch?: Maybe; }; @@ -966,6 +965,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']; diff --git a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx index f63d738d944..61f848741e7 100644 --- a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx +++ b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx @@ -1,9 +1,13 @@ import { aiModelsState } from '@/client-config/states/aiModelsState'; +import { isCodeInterpreterEnabledState } from '@/client-config/states/isCodeInterpreterEnabledState'; 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 { type ModelConfiguration } from 'twenty-shared/ai'; +import { + isAgentCapabilityEnabled, + type ModelConfiguration, +} from 'twenty-shared/ai'; import { isDefined } from 'twenty-shared/utils'; import { IconBrandX, IconCode, IconWorld } from 'twenty-ui/display'; import { Section } from 'twenty-ui/layout'; @@ -35,18 +39,21 @@ export const SettingsAgentModelCapabilities = ({ disabled = false, }: SettingsAgentModelCapabilitiesProps) => { const aiModels = useAtomStateValue(aiModelsState); + const isCodeInterpreterEnabled = useAtomStateValue( + isCodeInterpreterEnabledState, + ); const selectedModel = aiModels.find((m) => m.modelId === selectedModelId); - const capabilities = selectedModel?.capabilities; + const modelCapabilities = selectedModel?.capabilities; - if (!isDefined(capabilities)) { + if (!isDefined(modelCapabilities) && !isCodeInterpreterEnabled) { return null; } if ( - !capabilities.webSearch && - !capabilities.twitterSearch && - !capabilities.codeInterpreter + !modelCapabilities?.webSearch && + !modelCapabilities?.twitterSearch && + !isCodeInterpreterEnabled ) { return null; } @@ -69,15 +76,11 @@ export const SettingsAgentModelCapabilities = ({ }; const isCapabilityEnabled = (capability: AgentCapabilityKey) => { - if (capability === 'webSearch' || capability === 'codeInterpreter') { - return modelConfiguration[capability]?.enabled !== false; - } - - return modelConfiguration[capability]?.enabled || false; + return isAgentCapabilityEnabled(modelConfiguration, capability); }; const capabilityItems = [ - ...(capabilities.webSearch + ...(modelCapabilities?.webSearch ? [ { key: 'webSearch' as const, @@ -87,7 +90,7 @@ export const SettingsAgentModelCapabilities = ({ }, ] : []), - ...(capabilities.twitterSearch + ...(modelCapabilities?.twitterSearch ? [ { key: 'twitterSearch' as const, @@ -97,7 +100,7 @@ export const SettingsAgentModelCapabilities = ({ }, ] : []), - ...(capabilities.codeInterpreter + ...(isCodeInterpreterEnabled ? [ { key: 'codeInterpreter' as const, diff --git a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts index 65f9688bf99..0ded53b8ef1 100644 --- a/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/hooks/useClientConfig.ts @@ -8,6 +8,7 @@ import { canManageFeatureFlagsState } from '@/client-config/states/canManageFeat import { captchaState } from '@/client-config/states/captchaState'; import { isAnalyticsEnabledState } from '@/client-config/states/isAnalyticsEnabledState'; import { isAttachmentPreviewEnabledState } from '@/client-config/states/isAttachmentPreviewEnabledState'; +import { isCodeInterpreterEnabledState } from '@/client-config/states/isCodeInterpreterEnabledState'; import { isConfigVariablesInDbEnabledState } from '@/client-config/states/isConfigVariablesInDbEnabledState'; import { isDeveloperDefaultSignInPrefilledState } from '@/client-config/states/isDeveloperDefaultSignInPrefilledState'; import { isClickHouseConfiguredState } from '@/client-config/states/isClickHouseConfiguredState'; @@ -93,6 +94,9 @@ export const useClientConfig = (): UseClientConfigResult => { const setIsAttachmentPreviewEnabled = useSetAtomState( isAttachmentPreviewEnabledState, ); + const setIsCodeInterpreterEnabled = useSetAtomState( + isCodeInterpreterEnabledState, + ); const setIsConfigVariablesInDbEnabled = useSetAtomState( isConfigVariablesInDbEnabledState, @@ -185,6 +189,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsGoogleMessagingEnabled(clientConfig?.isGoogleMessagingEnabled); setIsGoogleCalendarEnabled(clientConfig?.isGoogleCalendarEnabled); setIsAttachmentPreviewEnabled(clientConfig?.isAttachmentPreviewEnabled); + setIsCodeInterpreterEnabled(clientConfig?.isCodeInterpreterEnabled); setIsConfigVariablesInDbEnabled( clientConfig?.isConfigVariablesInDbEnabled, ); @@ -229,6 +234,7 @@ export const useClientConfig = (): UseClientConfigResult => { setIsGoogleMessagingEnabled, setIsAnalyticsEnabled, setIsAttachmentPreviewEnabled, + setIsCodeInterpreterEnabled, setIsConfigVariablesInDbEnabled, setIsDeveloperDefaultSignInPrefilled, setIsEmailVerificationRequired, diff --git a/packages/twenty-front/src/modules/client-config/states/isCodeInterpreterEnabledState.ts b/packages/twenty-front/src/modules/client-config/states/isCodeInterpreterEnabledState.ts new file mode 100644 index 00000000000..412a1953656 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isCodeInterpreterEnabledState.ts @@ -0,0 +1,6 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const isCodeInterpreterEnabledState = createAtomState({ + key: 'isCodeInterpreterEnabledState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts index 660e2b7555e..0d5cbb6b437 100644 --- a/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/types/ClientConfig.ts @@ -23,6 +23,7 @@ export type ClientConfig = { defaultSubdomain?: string; frontDomain: string; isAttachmentPreviewEnabled: boolean; + isCodeInterpreterEnabled: boolean; isConfigVariablesInDbEnabled: boolean; isEmailVerificationRequired: boolean; isGoogleCalendarEnabled: boolean; diff --git a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts index 857f69a4d6a..9d4d0197ae9 100644 --- a/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts +++ b/packages/twenty-front/src/modules/client-config/utils/__tests__/clientConfigUtils.test.ts @@ -38,6 +38,7 @@ const mockClientConfig = { mutationMaximumAffectedRecords: 100, }, isAttachmentPreviewEnabled: true, + isCodeInterpreterEnabled: false, analyticsEnabled: false, canManageFeatureFlags: true, publicFeatureFlags: [], diff --git a/packages/twenty-front/src/pages/settings/ai/validation-schemas/settingsAIAgentFormSchema.ts b/packages/twenty-front/src/pages/settings/ai/validation-schemas/settingsAIAgentFormSchema.ts index 1c1b27e6d0b..246686fb919 100644 --- a/packages/twenty-front/src/pages/settings/ai/validation-schemas/settingsAIAgentFormSchema.ts +++ b/packages/twenty-front/src/pages/settings/ai/validation-schemas/settingsAIAgentFormSchema.ts @@ -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 diff --git a/packages/twenty-front/src/testing/mock-data/config.ts b/packages/twenty-front/src/testing/mock-data/config.ts index f2a4c3988b9..c509fe2c6db 100644 --- a/packages/twenty-front/src/testing/mock-data/config.ts +++ b/packages/twenty-front/src/testing/mock-data/config.ts @@ -51,6 +51,7 @@ export const mockedClientConfig: ClientConfig = { isGoogleMessagingEnabled: true, isGoogleCalendarEnabled: true, isAttachmentPreviewEnabled: true, + isCodeInterpreterEnabled: false, isConfigVariablesInDbEnabled: false, isImapSmtpCaldavEnabled: false, isTwoFactorAuthenticationEnabled: false, diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts index 50ae996709a..a021224bfc0 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.controller.spec.ts @@ -85,6 +85,7 @@ describe('ClientConfigController', () => { mutationMaximumAffectedRecords: 100, }, isAttachmentPreviewEnabled: true, + isCodeInterpreterEnabled: false, analyticsEnabled: false, canManageFeatureFlags: true, publicFeatureFlags: [], diff --git a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts index 678cbc93054..cbb2bbb6ea4 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/client-config.entity.ts @@ -36,9 +36,6 @@ export class AgentCapabilities { @Field(() => Boolean, { nullable: true }) twitterSearch?: boolean; - - @Field(() => Boolean, { nullable: true }) - codeInterpreter?: boolean; } @ObjectType() @@ -279,6 +276,9 @@ export class ClientConfig { @Field(() => Boolean) isAttachmentPreviewEnabled: boolean; + @Field(() => Boolean) + isCodeInterpreterEnabled: boolean; + @Field(() => Sentry) sentry: Sentry; diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts index e20574547ca..25c888b4d27 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.spec.ts @@ -11,7 +11,10 @@ import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain 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_XAI } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; +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'; describe('ClientConfigService', () => { @@ -172,6 +175,7 @@ describe('ClientConfigService', () => { mutationMaximumAffectedRecords: 1000, }, isAttachmentPreviewEnabled: true, + isCodeInterpreterEnabled: false, analyticsEnabled: true, canManageFeatureFlags: true, publicFeatureFlags: PUBLIC_FEATURE_FLAGS, @@ -283,6 +287,45 @@ describe('ClientConfigService', () => { webSearch: true, twitterSearch: true, }); + expect(result.isCodeInterpreterEnabled).toBe(false); + }); + + 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, + }); + expect(openAiModel?.capabilities).not.toHaveProperty('codeInterpreter'); }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts index 9e5b1ce61f9..c26b84f40db 100644 --- a/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts +++ b/packages/twenty-server/src/engine/core-modules/client-config/services/client-config.service.ts @@ -57,18 +57,13 @@ export class ClientConfigService { const webSearch = hasNativeWebSearch || isWebSearchDriverEnabled; - const codeInterpreter = - this.twentyConfigService.get('CODE_INTERPRETER_TYPE') !== - CodeInterpreterDriverType.DISABLED; - - if (!webSearch && !hasXSearchCapability && !codeInterpreter) { + if (!webSearch && !hasXSearchCapability) { return undefined; } return { ...(webSearch && { webSearch }), ...(hasXSearchCapability && { twitterSearch: true }), - ...(codeInterpreter && { codeInterpreter }), }; } @@ -245,6 +240,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') === diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type.ts new file mode 100644 index 00000000000..6f56fd4661f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type.ts @@ -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' +>; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts index 2d3bbcd9ac3..028ed9d6def 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type.ts @@ -2,7 +2,7 @@ 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 = { @@ -13,6 +13,6 @@ export type ToolProviderContext = { actorContext?: ActorMetadata; userId?: string; userWorkspaceId?: string; - agent?: FlatAgentWithRoleId | null; + agent?: ToolProviderAgent | null; onCodeExecutionUpdate?: CodeExecutionStreamEmitter; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 6d261d3b0fd..19aceb8e1ca 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isAgentCapabilityEnabled, ToolCategory } from 'twenty-shared/ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { z } from 'zod'; @@ -7,7 +8,6 @@ import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-pro 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 { ToolCategory } from 'twenty-shared/ai'; 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'; @@ -128,8 +128,10 @@ export class ActionToolProvider implements ToolProvider { ), ); - const isCodeInterpreterEnabledForAgent = - context.agent?.modelConfiguration?.codeInterpreter?.enabled !== false; + const isCodeInterpreterEnabledForAgent = isAgentCapabilityEnabled( + context.agent?.modelConfiguration, + 'codeInterpreter', + ); const hasCodeInterpreterPermission = this.codeInterpreterService.isEnabled() && @@ -150,8 +152,10 @@ export class ActionToolProvider implements ToolProvider { ); } - const isWebSearchEnabledForAgent = - context.agent?.modelConfiguration?.webSearch?.enabled !== false; + const isWebSearchEnabledForAgent = isAgentCapabilityEnabled( + context.agent?.modelConfiguration, + 'webSearch', + ); if (this.webSearchService.isEnabled() && isWebSearchEnabledForAgent) { descriptors.push( diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.spec.ts index be3eaf313b3..65b925016bb 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.spec.ts @@ -12,6 +12,7 @@ import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent describe('NativeModelToolProvider', () => { const agent = { id: 'agent-id', + modelId: 'xai-model', modelConfiguration: { webSearch: { enabled: true }, twitterSearch: { enabled: true }, @@ -19,6 +20,9 @@ describe('NativeModelToolProvider', () => { } as FlatAgentWithRoleId; const context = { + workspaceId: 'workspace-id', + roleId: 'role-id', + rolePermissionConfig: { unionOf: ['role-id'] }, agent, } as ToolProviderContext; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.spec.ts new file mode 100644 index 00000000000..8a50ed8d9ad --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.spec.ts @@ -0,0 +1,172 @@ +jest.mock('ai', () => { + const actual = jest.requireActual('ai'); + + return { + ...actual, + generateText: jest.fn(), + }; +}); + +import { generateText, type ToolSet } from 'ai'; + +import { type LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service'; +import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; +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 { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { type AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-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'; + +const createTool = (name: string): ToolSet[string] => + ({ + description: name, + inputSchema: {}, + execute: jest.fn(), + }) as unknown as ToolSet[string]; + +const createToolIndexEntry = ( + name: string, + category: ToolCategory, +): ToolIndexEntry => ({ + name, + category, + description: name, + executionRef: { kind: 'static', toolId: name }, +}); + +describe('AgentAsyncExecutorService', () => { + const mockedGenerateText = jest.mocked(generateText); + + beforeEach(() => { + mockedGenerateText.mockResolvedValue({ + text: 'Done', + steps: [], + usage: {} as never, + } as never); + }); + + it('builds workflow execution with native tools eager and database/action tools lazy', async () => { + const registeredModel = { + modelId: 'openai/gpt-4o', + sdkPackage: '@ai-sdk/openai', + model: {} as never, + } as RegisteredAIModel; + + const nativeModelTools = { + web_search: createTool('web_search'), + } as ToolSet; + + const runtimeTools = { + web_search: createTool('web_search'), + learn_tools: createTool('learn_tools'), + execute_tool: createTool('execute_tool'), + } as ToolSet; + + const lazyToolCatalog = [ + createToolIndexEntry('find_people', ToolCategory.DATABASE_CRUD), + createToolIndexEntry('send_email', ToolCategory.ACTION), + ]; + + const aiModelRegistryService = { + validateModelAvailability: jest.fn(), + resolveModelForAgent: jest.fn().mockReturnValue(registeredModel), + } as unknown as jest.Mocked; + + const agentModelConfigService = { + getProviderOptions: jest.fn().mockReturnValue({}), + } as unknown as jest.Mocked; + + const lazyToolRuntimeService = { + buildToolRuntime: jest.fn().mockResolvedValue({ + toolCatalog: lazyToolCatalog, + lazyToolCatalog, + directTools: nativeModelTools, + directToolNames: ['web_search'], + runtimeTools, + }), + } as unknown as jest.Mocked; + + const toolRegistry = { + getToolsByCategories: jest.fn().mockResolvedValue(nativeModelTools), + } as unknown as jest.Mocked; + + const roleTargetRepository = { + findOne: jest.fn().mockResolvedValue({ roleId: 'agent-role-id' }), + }; + + const workspace = { id: 'workspace-id' } as WorkspaceEntity; + const workspaceRepository = { + findOneBy: jest.fn().mockResolvedValue(workspace), + }; + + const service = new AgentAsyncExecutorService( + aiModelRegistryService, + agentModelConfigService, + lazyToolRuntimeService, + toolRegistry, + roleTargetRepository as never, + workspaceRepository as never, + ); + + const agent = { + id: 'agent-id', + workspaceId: 'workspace-id', + modelId: 'openai/gpt-4o', + prompt: 'Use tools carefully.', + modelConfiguration: { + webSearch: { enabled: true }, + codeInterpreter: { enabled: false }, + }, + responseFormat: { type: 'text' }, + } as unknown as AgentEntity; + + await service.executeAgent({ + agent, + userPrompt: 'Find the matching person.', + }); + + expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-id', + roleId: 'agent-role-id', + rolePermissionConfig: { intersectionOf: ['agent-role-id'] }, + agent: { + modelId: 'openai/gpt-4o', + modelConfiguration: agent.modelConfiguration, + }, + }), + { + categories: [ToolCategory.NATIVE_MODEL], + wrapWithErrorContext: false, + }, + ); + + expect(lazyToolRuntimeService.buildToolRuntime).toHaveBeenCalledWith({ + context: expect.objectContaining({ + workspaceId: 'workspace-id', + roleId: 'agent-role-id', + agent: { + modelId: 'openai/gpt-4o', + modelConfiguration: agent.modelConfiguration, + }, + }), + directTools: nativeModelTools, + lazyToolCategories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION], + }); + + expect(agentModelConfigService.getProviderOptions).toHaveBeenCalledWith( + registeredModel, + ); + expect(mockedGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: runtimeTools, + system: expect.stringContaining('`find_people`'), + }), + ); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 11ceafdab5c..6649b38ffcc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -14,6 +14,7 @@ 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 { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.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'; @@ -34,7 +35,10 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/ 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 { + countNativeWebSearchCallsFromSteps, + NATIVE_SEARCH_TOOL_NAMES, +} 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 { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; @@ -60,6 +64,11 @@ const WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES = [ ToolCategory.ACTION, ] as const; +const toToolProviderAgent = (agent: AgentEntity): ToolProviderAgent => ({ + modelId: agent.modelId, + modelConfiguration: agent.modelConfiguration ?? null, +}); + // Agent execution within workflows uses database and action tools only. // Workflow tools are intentionally excluded to avoid circular dependencies // and recursive workflow execution. @@ -130,22 +139,30 @@ export class AgentAsyncExecutorService { error instanceof Error ? (error as Error & { cause?: unknown }).cause : undefined; - const apiCallError = APICallError.isInstance(error) - ? error - : APICallError.isInstance(cause) - ? cause - : undefined; - if (apiCallError) { + if (APICallError.isInstance(error)) { return { - name: apiCallError.name, - message: apiCallError.message, - url: apiCallError.url, - statusCode: apiCallError.statusCode, - responseBody: apiCallError.responseBody, - isRetryable: apiCallError.isRetryable, + name: error.name, + message: error.message, + url: error.url, + statusCode: error.statusCode, + responseBody: error.responseBody, + isRetryable: error.isRetryable, }; } + + if (APICallError.isInstance(cause)) { + return { + name: cause.name, + message: cause.message, + url: cause.url, + statusCode: cause.statusCode, + responseBody: cause.responseBody, + isRetryable: cause.isRetryable, + }; + } + + return undefined; } private getErrorDetailsForLog(error: unknown): Record { @@ -178,8 +195,8 @@ export class AgentAsyncExecutorService { sdkPackage: context.sdkPackage, toolCount: context.toolNames.length, toolNames: context.toolNames, - nativeSearchToolNames: context.toolNames.filter( - (toolName) => toolName === 'web_search' || toolName === 'x_search', + nativeSearchToolNames: context.toolNames.filter((toolName) => + NATIVE_SEARCH_TOOL_NAMES.has(toolName), ), error: this.getErrorDetailsForLog(error), }; @@ -282,7 +299,7 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] }, authContext, actorContext, - agent: agent as unknown as ToolProviderContext['agent'], + agent: toToolProviderAgent(agent), userId: isDefined(authContext) && isUserAuthContext(authContext) ? authContext.user.id @@ -301,13 +318,11 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); }, ); - const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime( - { - context: toolProviderContext, - directTools: nativeModelTools, - lazyToolCategories: WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES, - }, - ); + const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({ + context: toolProviderContext, + directTools: nativeModelTools, + lazyToolCategories: WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES, + }); tools = toolRuntime.runtimeTools; lazyWorkflowToolCountForLog = toolRuntime.lazyToolCatalog.length; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts index ce21a267e2d..8c337dd6c9e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util.ts @@ -1,7 +1,8 @@ import { type StepResult, type ToolSet } from 'ai'; -// TODO: Confirm whether x_search should be billed the same as native web_search. -const NATIVE_SEARCH_TOOL_NAMES = new Set(['web_search', 'x_search']); +// x_search is billed alongside web_search because both represent +// provider-native search tool calls. +export const NATIVE_SEARCH_TOOL_NAMES = new Set(['web_search', 'x_search']); export const countNativeWebSearchCallsFromSteps = ( steps: StepResult[], diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts index 734b64fa283..2e44caf8ab0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts @@ -2,7 +2,9 @@ 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 { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { AI_SDK_ANTHROPIC, @@ -15,7 +17,6 @@ import { 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'; @Injectable() export class AgentModelConfigService { @@ -39,7 +40,7 @@ export class AgentModelConfigService { getNativeModelTools( model: RegisteredAIModel, - agent: FlatAgentWithRoleId, + agent: ToolProviderAgent, options: { useProviderNativeWebSearch: boolean }, ): ToolSet { const tools: ToolSet = {}; @@ -49,7 +50,7 @@ export class AgentModelConfigService { case AI_SDK_ANTHROPIC: if ( options.useProviderNativeWebSearch && - modelConfiguration.webSearch?.enabled !== false + isAgentCapabilityEnabled(modelConfiguration, 'webSearch') ) { const anthropicProvider = model.providerName ? this.sdkProviderFactory.getRawAnthropicProvider( @@ -65,7 +66,7 @@ export class AgentModelConfigService { case AI_SDK_BEDROCK: { if ( options.useProviderNativeWebSearch && - modelConfiguration.webSearch?.enabled !== false + isAgentCapabilityEnabled(modelConfiguration, 'webSearch') ) { const bedrockProvider = model.providerName ? this.sdkProviderFactory.getRawBedrockProvider(model.providerName) @@ -81,7 +82,7 @@ export class AgentModelConfigService { case AI_SDK_OPENAI: if ( options.useProviderNativeWebSearch && - modelConfiguration.webSearch?.enabled !== false + isAgentCapabilityEnabled(modelConfiguration, 'webSearch') ) { const openaiProvider = model.providerName ? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName) @@ -107,12 +108,12 @@ export class AgentModelConfigService { if ( options.useProviderNativeWebSearch && - modelConfiguration.webSearch?.enabled !== false + isAgentCapabilityEnabled(modelConfiguration, 'webSearch') ) { tools.web_search = xaiProvider.tools.webSearch() as ToolSet[string]; } - if (modelConfiguration.twitterSearch?.enabled) { + if (isAgentCapabilityEnabled(modelConfiguration, 'twitterSearch')) { tools.x_search = xaiProvider.tools.xSearch() as ToolSet[string]; } diff --git a/packages/twenty-shared/src/ai/constants/agent-capability-defaults.const.ts b/packages/twenty-shared/src/ai/constants/agent-capability-defaults.const.ts new file mode 100644 index 00000000000..f5bcb61a446 --- /dev/null +++ b/packages/twenty-shared/src/ai/constants/agent-capability-defaults.const.ts @@ -0,0 +1,7 @@ +import { type AgentCapability } from '../types/agent-capability.type'; + +export const AGENT_CAPABILITY_DEFAULTS = { + webSearch: true, + twitterSearch: false, + codeInterpreter: true, +} satisfies Record; diff --git a/packages/twenty-shared/src/ai/index.ts b/packages/twenty-shared/src/ai/index.ts index 9bb5a32150b..a91caa35aa7 100644 --- a/packages/twenty-shared/src/ai/index.ts +++ b/packages/twenty-shared/src/ai/index.ts @@ -8,6 +8,7 @@ */ export { AI_SDK_PACKAGE_LABELS } from './constants/ai-sdk-package-labels.const'; +export { AGENT_CAPABILITY_DEFAULTS } from './constants/agent-capability-defaults.const'; export type { AiSdkPackage } from './constants/ai-sdk-packages.const'; export { AI_SDK_PACKAGES } from './constants/ai-sdk-packages.const'; export type { DataResidency } from './constants/data-residency.const'; @@ -19,6 +20,7 @@ export type { AgentResponseFieldType, AgentResponseSchema, } from './types/agent-response-schema.type'; +export type { AgentCapability } from './types/agent-capability.type'; export type { AgentChatSubscriptionEvent } from './types/AgentChatSubscriptionEvent'; export type { CodeExecutionFile, @@ -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'; diff --git a/packages/twenty-shared/src/ai/types/agent-capability.type.ts b/packages/twenty-shared/src/ai/types/agent-capability.type.ts new file mode 100644 index 00000000000..861fc61688d --- /dev/null +++ b/packages/twenty-shared/src/ai/types/agent-capability.type.ts @@ -0,0 +1,3 @@ +import { type ModelConfiguration } from './model-configuration.type'; + +export type AgentCapability = keyof ModelConfiguration; diff --git a/packages/twenty-shared/src/ai/utils/__tests__/is-agent-capability-enabled.util.spec.ts b/packages/twenty-shared/src/ai/utils/__tests__/is-agent-capability-enabled.util.spec.ts new file mode 100644 index 00000000000..0b6fdfdb7de --- /dev/null +++ b/packages/twenty-shared/src/ai/utils/__tests__/is-agent-capability-enabled.util.spec.ts @@ -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); + }); +}); diff --git a/packages/twenty-shared/src/ai/utils/is-agent-capability-enabled.util.ts b/packages/twenty-shared/src/ai/utils/is-agent-capability-enabled.util.ts new file mode 100644 index 00000000000..0c395991a98 --- /dev/null +++ b/packages/twenty-shared/src/ai/utils/is-agent-capability-enabled.util.ts @@ -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] + ); +};