This commit is contained in:
ehconitin
2026-04-16 18:11:19 +05:30
parent a86cab7594
commit 78fd9a5efe
28 changed files with 399 additions and 70 deletions

View File

@@ -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!

View File

@@ -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

View File

@@ -3485,9 +3485,6 @@ export default {
"twitterSearch": [
6
],
"codeInterpreter": [
6
],
"__typename": [
1
]
@@ -3749,6 +3746,9 @@ export default {
"isAttachmentPreviewEnabled": [
6
],
"isCodeInterpreterEnabled": [
6
],
"sentry": [
185
],

View File

@@ -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'];

View File

@@ -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,

View File

@@ -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,

View File

@@ -0,0 +1,6 @@
import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState';
export const isCodeInterpreterEnabledState = createAtomState<boolean>({
key: 'isCodeInterpreterEnabledState',
defaultValue: false,
});

View File

@@ -23,6 +23,7 @@ export type ClientConfig = {
defaultSubdomain?: string;
frontDomain: string;
isAttachmentPreviewEnabled: boolean;
isCodeInterpreterEnabled: boolean;
isConfigVariablesInDbEnabled: boolean;
isEmailVerificationRequired: boolean;
isGoogleCalendarEnabled: boolean;

View File

@@ -38,6 +38,7 @@ const mockClientConfig = {
mutationMaximumAffectedRecords: 100,
},
isAttachmentPreviewEnabled: true,
isCodeInterpreterEnabled: false,
analyticsEnabled: false,
canManageFeatureFlags: true,
publicFeatureFlags: [],

View File

@@ -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

View File

@@ -51,6 +51,7 @@ export const mockedClientConfig: ClientConfig = {
isGoogleMessagingEnabled: true,
isGoogleCalendarEnabled: true,
isAttachmentPreviewEnabled: true,
isCodeInterpreterEnabled: false,
isConfigVariablesInDbEnabled: false,
isImapSmtpCaldavEnabled: false,
isTwoFactorAuthenticationEnabled: false,

View File

@@ -85,6 +85,7 @@ describe('ClientConfigController', () => {
mutationMaximumAffectedRecords: 100,
},
isAttachmentPreviewEnabled: true,
isCodeInterpreterEnabled: false,
analyticsEnabled: false,
canManageFeatureFlags: true,
publicFeatureFlags: [],

View File

@@ -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;

View File

@@ -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');
});
});
});

View File

@@ -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') ===

View File

@@ -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'
>;

View File

@@ -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;
};

View File

@@ -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(

View File

@@ -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;

View File

@@ -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`'),
}),
);
});
});

View File

@@ -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;

View File

@@ -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>[],

View File

@@ -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];
}

View File

@@ -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>;

View File

@@ -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';

View File

@@ -0,0 +1,3 @@
import { type ModelConfiguration } from './model-configuration.type';
export type AgentCapability = keyof ModelConfiguration;

View File

@@ -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);
});
});

View File

@@ -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]
);
};