mirror of
https://github.com/twentyhq/twenty.git
synced 2026-04-19 14:33:21 -04:00
more
This commit is contained in:
@@ -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!
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3485,9 +3485,6 @@ export default {
|
||||
"twitterSearch": [
|
||||
6
|
||||
],
|
||||
"codeInterpreter": [
|
||||
6
|
||||
],
|
||||
"__typename": [
|
||||
1
|
||||
]
|
||||
@@ -3749,6 +3746,9 @@ export default {
|
||||
"isAttachmentPreviewEnabled": [
|
||||
6
|
||||
],
|
||||
"isCodeInterpreterEnabled": [
|
||||
6
|
||||
],
|
||||
"sentry": [
|
||||
185
|
||||
],
|
||||
|
||||
@@ -165,7 +165,6 @@ export type Agent = {
|
||||
|
||||
export type AgentCapabilities = {
|
||||
__typename?: 'AgentCapabilities';
|
||||
codeInterpreter?: Maybe<Scalars['Boolean']>;
|
||||
twitterSearch?: Maybe<Scalars['Boolean']>;
|
||||
webSearch?: Maybe<Scalars['Boolean']>;
|
||||
};
|
||||
@@ -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'];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
|
||||
|
||||
export const isCodeInterpreterEnabledState = createAtomState<boolean>({
|
||||
key: 'isCodeInterpreterEnabledState',
|
||||
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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -85,6 +85,7 @@ describe('ClientConfigController', () => {
|
||||
mutationMaximumAffectedRecords: 100,
|
||||
},
|
||||
isAttachmentPreviewEnabled: true,
|
||||
isCodeInterpreterEnabled: false,
|
||||
analyticsEnabled: false,
|
||||
canManageFeatureFlags: true,
|
||||
publicFeatureFlags: [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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') ===
|
||||
|
||||
@@ -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,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;
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<AiModelRegistryService>;
|
||||
|
||||
const agentModelConfigService = {
|
||||
getProviderOptions: jest.fn().mockReturnValue({}),
|
||||
} as unknown as jest.Mocked<AgentModelConfigService>;
|
||||
|
||||
const lazyToolRuntimeService = {
|
||||
buildToolRuntime: jest.fn().mockResolvedValue({
|
||||
toolCatalog: lazyToolCatalog,
|
||||
lazyToolCatalog,
|
||||
directTools: nativeModelTools,
|
||||
directToolNames: ['web_search'],
|
||||
runtimeTools,
|
||||
}),
|
||||
} as unknown as jest.Mocked<LazyToolRuntimeService>;
|
||||
|
||||
const toolRegistry = {
|
||||
getToolsByCategories: jest.fn().mockResolvedValue(nativeModelTools),
|
||||
} as unknown as jest.Mocked<ToolRegistryService>;
|
||||
|
||||
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`'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ToolSet>[],
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AgentCapability, boolean>;
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { type ModelConfiguration } from './model-configuration.type';
|
||||
|
||||
export type AgentCapability = keyof ModelConfiguration;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../../constants/agent-capability-defaults.const';
|
||||
import { isAgentCapabilityEnabled } from '../is-agent-capability-enabled.util';
|
||||
|
||||
describe('isAgentCapabilityEnabled', () => {
|
||||
it('returns the shared defaults when an agent does not override a capability', () => {
|
||||
expect(isAgentCapabilityEnabled(undefined, 'webSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.webSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'twitterSearch')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.twitterSearch,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(undefined, 'codeInterpreter')).toBe(
|
||||
AGENT_CAPABILITY_DEFAULTS.codeInterpreter,
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers explicit agent configuration over the shared defaults', () => {
|
||||
const modelConfiguration = {
|
||||
webSearch: { enabled: false },
|
||||
twitterSearch: { enabled: true },
|
||||
codeInterpreter: { enabled: false },
|
||||
};
|
||||
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'webSearch')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isAgentCapabilityEnabled(modelConfiguration, 'twitterSearch')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isAgentCapabilityEnabled(modelConfiguration, 'codeInterpreter'),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { AGENT_CAPABILITY_DEFAULTS } from '../constants/agent-capability-defaults.const';
|
||||
import { type AgentCapability } from '../types/agent-capability.type';
|
||||
import { type ModelConfiguration } from '../types/model-configuration.type';
|
||||
|
||||
export const isAgentCapabilityEnabled = (
|
||||
modelConfiguration: ModelConfiguration | null | undefined,
|
||||
capability: AgentCapability,
|
||||
): boolean => {
|
||||
return (
|
||||
modelConfiguration?.[capability]?.enabled ??
|
||||
AGENT_CAPABILITY_DEFAULTS[capability]
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user