diff --git a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx index a27901de3d9..bb6698b673b 100644 --- a/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx +++ b/packages/twenty-front/src/modules/ai/components/SettingsAgentModelCapabilities.tsx @@ -34,17 +34,17 @@ export const SettingsAgentModelCapabilities = ({ disabled = false, }: SettingsAgentModelCapabilitiesProps) => { const aiModels = useAtomStateValue(aiModelsState); - const isCodeInterpreterEnabled = useAtomStateValue( + const isCodeInterpreterAvailable = useAtomStateValue( isCodeInterpreterEnabledState, ); const selectedModel = aiModels.find((m) => m.modelId === selectedModelId); - const modelCapabilities = selectedModel?.capabilities; + const availableModelCapabilities = selectedModel?.capabilities; if ( - !modelCapabilities?.webSearch && - !modelCapabilities?.twitterSearch && - !isCodeInterpreterEnabled + !availableModelCapabilities?.webSearch && + !availableModelCapabilities?.twitterSearch && + !isCodeInterpreterAvailable ) { return null; } @@ -66,8 +66,8 @@ export const SettingsAgentModelCapabilities = ({ }); }; - const capabilityItems = [ - ...(modelCapabilities?.webSearch + const modelCapabilityItems = [ + ...(availableModelCapabilities?.webSearch ? [ { key: 'webSearch' as const, @@ -77,7 +77,7 @@ export const SettingsAgentModelCapabilities = ({ }, ] : []), - ...(modelCapabilities?.twitterSearch + ...(availableModelCapabilities?.twitterSearch ? [ { key: 'twitterSearch' as const, @@ -90,19 +90,25 @@ export const SettingsAgentModelCapabilities = ({ }, ] : []), - ...(isCodeInterpreterEnabled - ? [ - { - key: 'codeInterpreter' as const, - label: t`Code Interpreter`, - Icon: IconCode, - enabled: isAgentCapabilityEnabled( - modelConfiguration, - 'codeInterpreter', - ), - }, - ] - : []), + ]; + + const workspaceCapabilityItems = isCodeInterpreterAvailable + ? [ + { + key: 'codeInterpreter' as const, + label: t`Code Interpreter`, + Icon: IconCode, + enabled: isAgentCapabilityEnabled( + modelConfiguration, + 'codeInterpreter', + ), + }, + ] + : []; + + const capabilityItems = [ + ...modelCapabilityItems, + ...workspaceCapabilityItems, ]; return ( diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts index 3add28eb2fa..c83cbe94c9e 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts @@ -15,7 +15,6 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api- import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { buildApiKeyAuthContext } from 'src/engine/core-modules/auth/utils/build-api-key-auth-context.util'; import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { createExecuteToolTool, @@ -108,10 +107,9 @@ export class McpProtocolService { userWorkspaceId?: string; }, ): Promise { - const toolContext: ToolProviderContext = { + const toolContext = { workspaceId: workspace.id, roleId, - rolePermissionConfig: { unionOf: [roleId] }, authContext: options?.authContext, userId: options?.userId, userWorkspaceId: options?.userWorkspaceId, 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 95a461ab60d..3ab7a1c5da9 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 @@ -29,8 +29,8 @@ registerEnumType(AiModelRole, { name: 'AiModelRole', }); -@ObjectType() -export class AgentCapabilities { +@ObjectType('AgentCapabilities') +export class ClientAiModelCapabilities { @Field(() => Boolean) webSearch: boolean; @@ -62,8 +62,9 @@ export class ClientAiModelConfig { @Field(() => Number, { nullable: true }) outputCostPerMillionTokens?: number; - @Field(() => AgentCapabilities) - capabilities: AgentCapabilities; + @Field(() => ClientAiModelCapabilities) + // Model-level availability. Agent-level on/off state lives in modelConfiguration. + capabilities: ClientAiModelCapabilities; @Field(() => Boolean, { nullable: true }) isDeprecated?: boolean; 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 df172d9f5d1..831ce703489 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 @@ -291,6 +291,40 @@ describe('ClientConfigService', () => { expect(result.isCodeInterpreterEnabled).toBe(false); }); + it('keeps x search available when external web search is preferred', async () => { + jest + .spyOn(aiModelRegistryService, 'getAdminFilteredModels') + .mockReturnValue([ + { + modelId: 'xai-model', + sdkPackage: AI_SDK_XAI, + model: {} as never, + providerName: 'xai', + }, + ]); + + jest + .spyOn(twentyConfigService, 'get') + .mockImplementation((key: string) => { + if (key === 'WEB_SEARCH_DRIVER') return WebSearchDriverType.EXA; + if (key === 'WEB_SEARCH_PREFER_NATIVE') return false; + if (key === 'CODE_INTERPRETER_TYPE') + return CodeInterpreterDriverType.DISABLED; + + return undefined; + }); + + const result = await service.getClientConfig(); + const xaiModel = result.aiModels.find( + (model) => model.modelId === 'xai-model', + ); + + expect(xaiModel?.capabilities).toEqual({ + webSearch: true, + twitterSearch: true, + }); + }); + it('surfaces code interpreter availability at the client-config level', async () => { jest .spyOn(aiModelRegistryService, 'getAdminFilteredModels') 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 9774a466c09..62ce839576f 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 @@ -17,7 +17,7 @@ import { import { MaintenanceModeService } from 'src/engine/core-modules/admin-panel/maintenance-mode.service'; import { - type AgentCapabilities, + type ClientAiModelCapabilities, type ClientAiModelConfig, type ClientConfig, } from 'src/engine/core-modules/client-config/client-config.entity'; @@ -40,9 +40,9 @@ export class ClientConfigService { private maintenanceModeService: MaintenanceModeService, ) {} - private deriveAvailableModelCapabilities( + private deriveModelCapabilities( sdkPackage?: AiSdkPackage, - ): AgentCapabilities { + ): ClientAiModelCapabilities { const supportsProviderNativeWebSearch = sdkPackage === AI_SDK_OPENAI || sdkPackage === AI_SDK_ANTHROPIC || @@ -104,7 +104,7 @@ export class ClientConfigService { sdkPackage: registeredModel.sdkPackage, providerName, providerLabel: getProviderLabel(providerName), - capabilities: this.deriveAvailableModelCapabilities( + capabilities: this.deriveModelCapabilities( registeredModel.sdkPackage, ), inputCostPerMillionTokens: modelConfig?.inputCostPerMillionTokens, @@ -144,7 +144,7 @@ export class ClientConfigService { defaultPerformanceModel?.providerName, ), sdkPackage: defaultPerformanceModel?.sdkPackage ?? null, - capabilities: this.deriveAvailableModelCapabilities( + capabilities: this.deriveModelCapabilities( defaultPerformanceModel?.sdkPackage, ), inputCostPerMillionTokens: @@ -165,7 +165,7 @@ export class ClientConfigService { providerName: defaultSpeedModel?.providerName, providerLabel: getProviderLabel(defaultSpeedModel?.providerName), sdkPackage: defaultSpeedModel?.sdkPackage ?? null, - capabilities: this.deriveAvailableModelCapabilities( + capabilities: this.deriveModelCapabilities( defaultSpeedModel?.sdkPackage, ), inputCostPerMillionTokens: 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 028ed9d6def..76e6a87be95 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 @@ -16,3 +16,14 @@ export type ToolProviderContext = { agent?: ToolProviderAgent | null; onCodeExecutionUpdate?: CodeExecutionStreamEmitter; }; + +export type ToolContext = Pick< + ToolProviderContext, + | 'workspaceId' + | 'roleId' + | 'authContext' + | 'actorContext' + | 'userId' + | 'userWorkspaceId' + | 'onCodeExecutionUpdate' +>; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts index 2bf52db5b75..64f2e373d0d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; import { isDefined } from 'twenty-shared/utils'; @@ -15,8 +15,6 @@ import { ToolCategory } from 'twenty-shared/ai'; // This provider keeps generateTools() and is excluded from the descriptor system. @Injectable() export class NativeModelToolProvider implements NativeToolProvider { - private readonly logger = new Logger(NativeModelToolProvider.name); - readonly category = ToolCategory.NATIVE_MODEL; constructor( @@ -37,10 +35,6 @@ export class NativeModelToolProvider implements NativeToolProvider { const useProviderNativeWebSearch = this.webSearchService.shouldUseNativeSearch(); - this.logger.log( - `Web search strategy: ${useProviderNativeWebSearch ? 'native (provider SDK)' : 'external (EXA)'}`, - ); - const registeredModel = await this.aiModelRegistryService.resolveModelForAgent(context.agent); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.spec.ts deleted file mode 100644 index 418deed64c8..00000000000 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { type ToolSet } from 'ai'; - -import { ToolCategory } from 'twenty-shared/ai'; - -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; -import { 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 { - EXECUTE_TOOL_TOOL_NAME, - LEARN_TOOLS_TOOL_NAME, -} from 'src/engine/core-modules/tool-provider/tools'; -import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; - -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('LazyToolRuntimeService', () => { - const context: ToolProviderContext = { - workspaceId: 'workspace-id', - roleId: 'role-id', - rolePermissionConfig: { unionOf: ['role-id'] }, - }; - - const setup = () => { - const toolRegistry = { - getCatalog: jest.fn(), - getToolsByName: jest.fn(), - getToolInfo: jest.fn(), - resolveAndExecute: jest.fn(), - } as unknown as jest.Mocked; - - const service = new LazyToolRuntimeService(toolRegistry); - - return { service, toolRegistry }; - }; - - it('builds runtime tools from direct tools', async () => { - const { service, toolRegistry } = setup(); - - toolRegistry.getCatalog.mockResolvedValue([ - createToolIndexEntry('search_help_center', ToolCategory.ACTION), - ]); - - const runtime = await service.buildToolRuntime({ - context, - directTools: { - search_help_center: createTool('search_help_center'), - x_search: createTool('x_search'), - }, - }); - - expect(runtime.directToolNames).toEqual(['search_help_center', 'x_search']); - expect(Object.keys(runtime.runtimeTools)).toEqual([ - 'search_help_center', - 'x_search', - LEARN_TOOLS_TOOL_NAME, - EXECUTE_TOOL_TOOL_NAME, - ]); - }); - - it('filters lazy tools by category while keeping direct tools callable', async () => { - const { service, toolRegistry } = setup(); - - toolRegistry.getCatalog.mockResolvedValue([ - createToolIndexEntry('find_companies', ToolCategory.DATABASE_CRUD), - createToolIndexEntry('create_workflow', ToolCategory.WORKFLOW), - ]); - toolRegistry.getToolInfo.mockImplementation(async (toolNames: string[]) => - toolNames.map((toolName) => ({ - name: toolName, - description: toolName, - })), - ); - - const runtime = await service.buildToolRuntime({ - context, - directTools: { - x_search: createTool('x_search'), - }, - lazyToolCategories: [ToolCategory.DATABASE_CRUD], - }); - - expect(runtime.lazyToolCatalog.map((tool) => tool.name)).toEqual([ - 'find_companies', - ]); - expect(runtime.runtimeTools.x_search).toBeDefined(); - - const learnTools = runtime.runtimeTools[ - LEARN_TOOLS_TOOL_NAME - ] as unknown as { - execute: (parameters: { - toolNames: string[]; - aspects: ['description']; - }) => Promise; - }; - - await learnTools.execute({ - toolNames: ['find_companies', 'create_workflow'], - aspects: ['description'], - }); - - expect(toolRegistry.getToolInfo).toHaveBeenCalledWith( - ['find_companies'], - context, - ['description'], - ); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.ts deleted file mode 100644 index ed81072ce22..00000000000 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type ToolSet } from 'ai'; - -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; -import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; -import { - createExecuteToolTool, - createLearnToolsTool, - EXECUTE_TOOL_TOOL_NAME, - LEARN_TOOLS_TOOL_NAME, -} from 'src/engine/core-modules/tool-provider/tools'; -import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; -import { ToolCategory } from 'twenty-shared/ai'; - -export type LazyToolRuntime = { - toolCatalog: ToolIndexEntry[]; - lazyToolCatalog: ToolIndexEntry[]; - directTools: ToolSet; - directToolNames: string[]; - runtimeTools: ToolSet; -}; - -@Injectable() -export class LazyToolRuntimeService { - constructor(private readonly toolRegistry: ToolRegistryService) {} - - async buildToolRuntime({ - context, - directTools = {}, - lazyToolCategories, - }: { - context: ToolProviderContext; - directTools?: ToolSet; - lazyToolCategories?: readonly ToolCategory[]; - }): Promise { - const toolCatalog = await this.toolRegistry.getCatalog(context); - const lazyToolCatalog = this.filterLazyToolCatalog( - toolCatalog, - lazyToolCategories, - ); - - const lazyToolNames = new Set(lazyToolCatalog.map((tool) => tool.name)); - const excludedToolNames = new Set([ - ...toolCatalog - .filter((tool) => !lazyToolNames.has(tool.name)) - .map((tool) => tool.name), - ...Object.keys(directTools), - ]); - - return { - toolCatalog, - lazyToolCatalog, - directTools, - directToolNames: Object.keys(directTools), - runtimeTools: { - ...directTools, - [LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool( - this.toolRegistry, - context, - excludedToolNames, - ), - [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool( - this.toolRegistry, - context, - directTools, - excludedToolNames, - ), - }, - }; - } - - private filterLazyToolCatalog( - toolCatalog: ToolIndexEntry[], - lazyToolCategories?: readonly ToolCategory[], - ): ToolIndexEntry[] { - if (!lazyToolCategories) { - return toolCatalog; - } - - const categorySet = new Set(lazyToolCategories); - - return toolCatalog.filter((tool) => categorySet.has(tool.category)); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 0d72b43df4a..25ed2e49d05 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -3,11 +3,15 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { type ToolExecutionOptions, type ToolSet, jsonSchema } from 'ai'; import { type NativeToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/native-tool-provider.interface'; -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; +import { + type ToolContext, + type ToolProviderContext, +} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolRetrievalOptions } from 'src/engine/core-modules/tool-provider/interfaces/tool-retrieval-options.type'; import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; +import { ToolCategory } from 'twenty-shared/ai'; import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider'; import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; @@ -16,7 +20,7 @@ import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types import { wrapWithErrorHandler } from 'src/engine/core-modules/tool-provider/utils/tool-error.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; -import { ToolCategory } from 'twenty-shared/ai'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @Injectable() export class ToolRegistryService { @@ -133,24 +137,27 @@ export class ToolRegistryService { roleId: string, options?: { userId?: string; userWorkspaceId?: string }, ): Promise { - return this.getCatalog({ + const context = this.buildContextFromToolContext({ workspaceId, roleId, - rolePermissionConfig: { unionOf: [roleId] }, userId: options?.userId, userWorkspaceId: options?.userWorkspaceId, }); + + return this.getCatalog(context); } async getToolsByName( names: string[], - context: ToolProviderContext, + context: ToolContext, ): Promise { - const index = await this.getCatalog(context); + const fullContext = this.buildContextFromToolContext(context); + + const index = await this.getCatalog(fullContext); const nameSet = new Set(names); const matchingEntries = index.filter((entry) => nameSet.has(entry.name)); - const schemas = await this.resolveSchemas(names, context); + const schemas = await this.resolveSchemas(names, fullContext); const descriptors: ToolDescriptor[] = matchingEntries .filter((entry) => schemas.has(entry.name)) @@ -159,24 +166,26 @@ export class ToolRegistryService { inputSchema: schemas.get(entry.name)!, })); - return this.hydrateToolSet(descriptors, context); + return this.hydrateToolSet(descriptors, fullContext); } async getToolInfo( names: string[], - context: ToolProviderContext, + context: ToolContext, aspects: LearnToolsAspect[] = ['description', 'schema'], ): Promise< Array<{ name: string; description?: string; inputSchema?: object }> > { - const index = await this.getCatalog(context); + const fullContext = this.buildContextFromToolContext(context); + + const index = await this.getCatalog(fullContext); const nameSet = new Set(names); const matchingEntries = index.filter((entry) => nameSet.has(entry.name)); let schemas: Map | undefined; if (aspects.includes('schema')) { - schemas = await this.resolveSchemas(names, context); + schemas = await this.resolveSchemas(names, fullContext); } return matchingEntries.map((entry) => { @@ -201,11 +210,13 @@ export class ToolRegistryService { async resolveAndExecute( toolName: string, args: Record, - context: ToolProviderContext, + context: ToolContext, _options: ToolExecutionOptions, ): Promise { try { - const index = await this.getCatalog(context); + const fullContext = this.buildContextFromToolContext(context); + + const index = await this.getCatalog(fullContext); const entry = index.find((indexEntry) => indexEntry.name === toolName); if (!entry) { @@ -216,7 +227,7 @@ export class ToolRegistryService { }; } - return await this.toolExecutorService.dispatch(entry, args, context); + return await this.toolExecutorService.dispatch(entry, args, fullContext); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); @@ -288,4 +299,22 @@ export class ToolRegistryService { return toolSet; } + + private buildContextFromToolContext( + context: ToolContext, + ): ToolProviderContext { + const rolePermissionConfig: RolePermissionConfig = { + unionOf: [context.roleId], + }; + + return { + workspaceId: context.workspaceId, + roleId: context.roleId, + rolePermissionConfig, + authContext: context.authContext, + userId: context.userId, + userWorkspaceId: context.userWorkspaceId, + onCodeExecutionUpdate: context.onCodeExecutionUpdate, + }; + } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 223a1080fdd..a2cc6e23232 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -13,7 +13,6 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p import { ViewFieldToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-field-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider'; -import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service'; import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -105,9 +104,8 @@ import { ToolRegistryService } from './services/tool-registry.service'; WorkflowToolProvider, ], }, - LazyToolRuntimeService, ToolRegistryService, ], - exports: [LazyToolRuntimeService, ToolRegistryService], + exports: [ToolRegistryService], }) export class ToolProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts index e9c0f807993..ca9b9b5409f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -2,8 +2,8 @@ import { jsonSchema, type ToolExecutionOptions, type ToolSet } from 'ai'; import { type JSONSchema7 } from 'json-schema'; import { z } from 'zod'; -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool'; @@ -43,7 +43,7 @@ export const executeToolInputSchema = jsonSchema( export const createExecuteToolTool = ( toolRegistry: ToolRegistryService, - context: ToolProviderContext, + context: ToolContext, directTools?: ToolSet, excludeTools?: Set, ) => ({ diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts index 998a3952669..ec1d424928e 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { type ToolContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; export const LEARN_TOOLS_TOOL_NAME = 'learn_tools'; @@ -38,7 +38,7 @@ export type LearnToolsResult = { export const createLearnToolsTool = ( toolRegistry: ToolRegistryService, - context: ToolProviderContext, + context: ToolContext, excludeTools?: Set, ) => ({ description: diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/wrap-tool-for-execution.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/wrap-tool-for-execution.util.spec.ts deleted file mode 100644 index e1da2a6ac80..00000000000 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/__tests__/wrap-tool-for-execution.util.spec.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { wrapJsonSchemaForExecution } from '../wrap-tool-for-execution.util'; - -describe('wrapJsonSchemaForExecution', () => { - it('preserves schema metadata such as $defs and additionalProperties', () => { - const filterSchema = { - type: 'object', - properties: { - and: { - type: 'array', - items: { - $ref: '#/$defs/condition', - }, - }, - }, - required: ['and'], - }; - - const inputSchema = { - type: 'object', - properties: { - filter: { - $ref: '#/$defs/filter', - }, - }, - required: ['filter'], - additionalProperties: false, - $defs: { - filter: filterSchema, - condition: { - type: 'object', - properties: { - eq: { type: 'string' }, - }, - }, - }, - }; - - const wrappedSchema = wrapJsonSchemaForExecution(inputSchema); - - expect(wrappedSchema.$defs).toEqual(inputSchema.$defs); - expect(wrappedSchema.additionalProperties).toBe(false); - expect(wrappedSchema.properties).toMatchObject({ - filter: { $ref: '#/$defs/filter' }, - loadingMessage: { - type: 'string', - description: 'A brief status message for the user.', - }, - }); - expect(wrappedSchema.required).toEqual( - expect.arrayContaining(['loadingMessage', 'filter']), - ); - }); - - it('deduplicates loadingMessage in required fields', () => { - const wrappedSchema = wrapJsonSchemaForExecution({ - type: 'object', - properties: { - query: { type: 'string' }, - }, - required: ['loadingMessage', 'query'], - }); - - expect(wrappedSchema.required).toEqual(['loadingMessage', 'query']); - }); - - it('builds a valid object schema when optional keys are missing', () => { - const wrappedSchema = wrapJsonSchemaForExecution({}); - - expect(wrappedSchema.type).toBe('object'); - expect(wrappedSchema.properties).toEqual({ - loadingMessage: { - type: 'string', - description: 'A brief status message for the user.', - }, - }); - expect(wrappedSchema.required).toEqual(['loadingMessage']); - }); -}); diff --git a/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts b/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts index f3721dd4fcf..c2518a555d1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/utils/wrap-tool-for-execution.util.ts @@ -1,4 +1,3 @@ -import { isArray, isObject } from '@sniptt/guards'; import { z } from 'zod'; const DEFAULT_LOADING_MESSAGE_SCHEMA = z @@ -23,16 +22,10 @@ export const wrapSchemaForExecution = ( export const wrapJsonSchemaForExecution = ( schema: Record, ): Record => { - const properties = - isObject(schema.properties) && !isArray(schema.properties) - ? (schema.properties as Record) - : {}; - const required = isArray(schema.required) - ? schema.required.filter((item): item is string => typeof item === 'string') - : []; + const properties = (schema.properties as Record) ?? {}; + const required = (schema.required as string[]) ?? []; return { - ...schema, type: 'object', properties: { loadingMessage: { @@ -41,7 +34,7 @@ export const wrapJsonSchemaForExecution = ( }, ...properties, }, - required: [...new Set(['loadingMessage', ...required])], + required: ['loadingMessage', ...required], }; }; 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 index 2c32d7d5a4a..cb437b05d82 100644 --- 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 @@ -9,9 +9,7 @@ jest.mock('ai', () => { 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'; @@ -22,27 +20,74 @@ import { } 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); + const registeredModel = { + modelId: 'xai/grok', + sdkPackage: '@ai-sdk/xai', + model: {} as never, + } as RegisteredAiModel; + + const createService = ({ + roleId, + tools = {}, + }: { + roleId?: string; + tools?: ToolSet; + }) => { + const aiModelRegistryService = { + validateModelAvailability: jest.fn(), + resolveModelForAgent: jest.fn().mockResolvedValue(registeredModel), + } as unknown as jest.Mocked; + + const aiModelConfigService = { + getProviderOptions: jest.fn().mockReturnValue({}), + } as unknown as jest.Mocked; + + const toolRegistry = { + getToolsByCategories: jest.fn().mockResolvedValue(tools), + } as unknown as jest.Mocked; + + const roleTargetRepository = { + findOne: jest.fn().mockResolvedValue(roleId ? { roleId } : null), + }; + + const workspaceRepository = { + findOneBy: jest + .fn() + .mockResolvedValue({ id: 'workspace-id' } as WorkspaceEntity), + }; + + const service = new AgentAsyncExecutorService( + aiModelRegistryService, + aiModelConfigService, + toolRegistry, + roleTargetRepository as never, + workspaceRepository as never, + ); + + return { + service, + aiModelConfigService, + toolRegistry, + }; + }; + + const agent = { + id: 'agent-id', + workspaceId: 'workspace-id', + modelId: 'xai/grok', + prompt: 'Be helpful.', + modelConfiguration: { + webSearch: { enabled: true }, + twitterSearch: { enabled: true }, + }, + responseFormat: { type: 'text' }, + } as unknown as AgentEntity; + beforeEach(() => { + mockedGenerateText.mockReset(); mockedGenerateText.mockResolvedValue({ text: 'Done', steps: [], @@ -50,130 +95,67 @@ describe('AgentAsyncExecutorService', () => { } 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 aiModelConfigService = { - 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, - aiModelConfigService, - 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; + it('does not load workflow tools when the agent has no explicit role', async () => { + const { service, toolRegistry } = createService({ + roleId: undefined, + }); await service.executeAgent({ agent, - userPrompt: 'Find the matching person.', + userPrompt: 'Find a record.', + rolePermissionConfig: { unionOf: ['workflow-role-id'] }, + }); + + expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled(); + expect(mockedGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: {}, + }), + ); + }); + + it('intersects the saved agent role with workflow execution permissions', async () => { + const tools = { + x_search: { + description: 'Search X', + inputSchema: {}, + execute: jest.fn(), + }, + } as unknown as ToolSet; + const { service, aiModelConfigService, toolRegistry } = createService({ + roleId: 'agent-role-id', + tools, + }); + + await service.executeAgent({ + agent, + userPrompt: 'Find a record.', + rolePermissionConfig: { unionOf: ['workflow-role-id'] }, }); expect(toolRegistry.getToolsByCategories).toHaveBeenCalledWith( expect.objectContaining({ workspaceId: 'workspace-id', roleId: 'agent-role-id', - rolePermissionConfig: { intersectionOf: ['agent-role-id'] }, + rolePermissionConfig: { + intersectionOf: ['agent-role-id', 'workflow-role-id'], + }, agent: { - modelId: 'openai/gpt-4o', + modelId: 'xai/grok', modelConfiguration: agent.modelConfiguration, }, }), { - categories: [ToolCategory.NATIVE_MODEL], + categories: [ + ToolCategory.DATABASE_CRUD, + ToolCategory.ACTION, + 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(aiModelConfigService.getProviderOptions).toHaveBeenCalledWith( registeredModel, ); - expect(mockedGenerateText).toHaveBeenCalledWith( - expect.objectContaining({ - tools: runtimeTools, - system: expect.stringContaining('`find_people`'), - }), - ); - - const firstGenerateTextCall = mockedGenerateText.mock.calls[0]?.[0]; - - expect(firstGenerateTextCall).toBeDefined(); - expect(Object.keys(firstGenerateTextCall?.tools ?? {})).not.toEqual( - expect.arrayContaining(['find_people', 'send_email']), - ); }); }); 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 a5879f1923c..ba7c9d7e7d6 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 @@ -12,23 +12,13 @@ import { type ActorMetadata } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type Repository } from 'typeorm'; -import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard'; -import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type ToolProviderAgent } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-agent.type'; import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; -import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service'; +import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { ToolCategory } from 'twenty-shared/ai'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; -import { - EXECUTE_TOOL_TOOL_NAME, - LEARN_TOOLS_TOOL_NAME, -} from 'src/engine/core-modules/tool-provider/tools'; -import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; -import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; -import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; -import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { countNativeWebSearchCallsFromSteps } from 'src/engine/metadata-modules/ai/ai-billing/utils/count-native-web-search-calls-from-steps.util'; import { 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'; @@ -36,17 +26,21 @@ import { AiException, AiExceptionCode, } from 'src/engine/metadata-modules/ai/ai.exception'; +import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; +import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; +import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; -import { ToolCategory } from 'twenty-shared/ai'; -const WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES = [ - ToolCategory.DATABASE_CRUD, - ToolCategory.ACTION, -] as const; +type EffectiveAgentPermissions = { + agentRoleId: string; + rolePermissionConfig: RolePermissionConfig; +}; const toToolProviderAgent = (agent: AgentEntity): ToolProviderAgent => ({ modelId: agent.modelId, @@ -63,7 +57,6 @@ export class AgentAsyncExecutorService { constructor( private readonly aiModelRegistryService: AiModelRegistryService, private readonly aiModelConfigService: AiModelConfigService, - private readonly lazyToolRuntimeService: LazyToolRuntimeService, private readonly toolRegistry: ToolRegistryService, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, @@ -89,11 +82,41 @@ export class AgentAsyncExecutorService { return []; } + private describeRolePermissionConfig( + rolePermissionConfig?: RolePermissionConfig, + ): string { + if (!rolePermissionConfig) { + return 'none'; + } + + if ('shouldBypassPermissionChecks' in rolePermissionConfig) { + return 'bypass'; + } + + if ('intersectionOf' in rolePermissionConfig) { + return `intersectionOf=[${rolePermissionConfig.intersectionOf.join(', ')}]`; + } + + if ('unionOf' in rolePermissionConfig) { + return `unionOf=[${rolePermissionConfig.unionOf.join(', ')}]`; + } + + return 'unknown'; + } + + private extractAttemptedToolNames( + steps: Array<{ toolCalls: Array<{ toolName: string }> }>, + ): string[] { + return steps.flatMap((step) => + step.toolCalls.map((toolCall) => toolCall.toolName), + ); + } + private async getEffectiveRolePermissionConfig( agentId: string, workspaceId: string, rolePermissionConfig?: RolePermissionConfig, - ): Promise { + ): Promise { const roleTarget = await this.roleTargetRepository.findOne({ where: { agentId, @@ -103,60 +126,19 @@ export class AgentAsyncExecutorService { }); const agentRoleId = roleTarget?.roleId; - const configRoleIds = this.extractRoleIds(rolePermissionConfig); - const allRoleIds = agentRoleId - ? [...new Set([...configRoleIds, agentRoleId])] - : configRoleIds; - - if (allRoleIds.length === 0) { + if (!agentRoleId) { return undefined; } - return { intersectionOf: allRoleIds }; - } + const workflowRoleIds = this.extractRoleIds(rolePermissionConfig); - private buildWorkflowToolCatalogPrompt({ - toolCatalog, - directToolNames, - }: { - toolCatalog: ToolIndexEntry[]; - directToolNames: string[]; - }): string { - const toolsByCategory = new Map(); - - for (const tool of toolCatalog) { - const existing = toolsByCategory.get(tool.category) ?? []; - - existing.push(tool); - toolsByCategory.set(tool.category, existing); - } - - const directToolsSection = - directToolNames.length > 0 - ? `Direct native model tools available now: ${directToolNames.map((toolName) => `\`${toolName}\``).join(', ')}.` - : 'No direct native model tools are available.'; - - const sections = [ - `## Available Workflow Tools - -${directToolsSection} - -For database and action tools, first call \`${LEARN_TOOLS_TOOL_NAME}\` with the exact tool name to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` with matching arguments. Do not call tools that are not listed below.`, - ]; - - for (const category of WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES) { - const tools = toolsByCategory.get(category); - - if (!tools || tools.length === 0) { - continue; - } - - sections.push(`### ${category} -${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); - } - - return sections.join('\n\n'); + return { + agentRoleId, + rolePermissionConfig: { + intersectionOf: [...new Set([agentRoleId, ...workflowRoleIds])], + }, + }; } async executeAgent({ @@ -172,7 +154,9 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); rolePermissionConfig?: RolePermissionConfig; authContext?: WorkspaceAuthContext; }): Promise { - let lazyWorkflowToolCount = 0; + let registeredModel: RegisteredAiModel | undefined; + let generatedToolNames: string[] = []; + let effectiveAgentPermissions: EffectiveAgentPermissions | undefined; try { if (agent) { @@ -188,72 +172,69 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); } } - const registeredModel = + registeredModel = await this.aiModelRegistryService.resolveModelForAgent(agent); let tools: ToolSet = {}; let providerOptions = {}; - let workflowToolCatalogPrompt = ''; + const workflowRoleIds = this.extractRoleIds(rolePermissionConfig); if (agent) { - const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig( + effectiveAgentPermissions = await this.getEffectiveRolePermissionConfig( agent.id, agent.workspaceId, rolePermissionConfig, ); - const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? ''; - const toolProviderContext: ToolProviderContext = { - workspaceId: agent.workspaceId, - roleId, - rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] }, - authContext, - actorContext, - agent: toToolProviderAgent(agent), - userId: - isDefined(authContext) && isUserAuthContext(authContext) - ? authContext.user.id - : undefined, - userWorkspaceId: - isDefined(authContext) && isUserAuthContext(authContext) - ? authContext.userWorkspaceId - : undefined, - }; - - const nativeModelTools = await this.toolRegistry.getToolsByCategories( - toolProviderContext, - { - categories: [ToolCategory.NATIVE_MODEL], - wrapWithErrorContext: false, - }, - ); - - const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({ - context: toolProviderContext, - directTools: nativeModelTools, - lazyToolCategories: WORKFLOW_AGENT_LAZY_TOOL_CATEGORIES, - }); - - tools = toolRuntime.runtimeTools; - lazyWorkflowToolCount = toolRuntime.lazyToolCatalog.length; - workflowToolCatalogPrompt = this.buildWorkflowToolCatalogPrompt({ - toolCatalog: toolRuntime.lazyToolCatalog, - directToolNames: toolRuntime.directToolNames, - }); + if (effectiveAgentPermissions) { + tools = await this.toolRegistry.getToolsByCategories( + { + workspaceId: agent.workspaceId, + roleId: effectiveAgentPermissions.agentRoleId, + rolePermissionConfig: + effectiveAgentPermissions.rolePermissionConfig, + authContext, + actorContext, + agent: toToolProviderAgent(agent), + userId: + isDefined(authContext) && isUserAuthContext(authContext) + ? authContext.user.id + : undefined, + userWorkspaceId: + isDefined(authContext) && isUserAuthContext(authContext) + ? authContext.userWorkspaceId + : undefined, + }, + { + categories: [ + ToolCategory.DATABASE_CRUD, + ToolCategory.ACTION, + ToolCategory.NATIVE_MODEL, + ], + wrapWithErrorContext: false, + }, + ); + } providerOptions = this.aiModelConfigService.getProviderOptions( registeredModel, ); + + generatedToolNames = Object.keys(tools).sort(); + + this.logger.log( + `Workflow agent tool context: agentId=${agent.id} modelId=${registeredModel.modelId} workflowRoleIds=[${workflowRoleIds.join(', ')}] savedAgentRoleId=${effectiveAgentPermissions?.agentRoleId ?? 'none'} effectiveRolePermissionConfig=${this.describeRolePermissionConfig(effectiveAgentPermissions?.rolePermissionConfig)} toolCount=${generatedToolNames.length}`, + ); + + if (generatedToolNames.length > 0) { + this.logger.log( + `Workflow agent generated tools for ${agent.id}: ${generatedToolNames.join(', ')}`, + ); + } } - const runtimeToolCount = Object.keys(tools).length; - - this.logger.log( - `Generated ${runtimeToolCount} runtime tools and ${lazyWorkflowToolCount} lazy workflow tools for agent`, - ); - const textResponse = await generateText({ - system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${workflowToolCatalogPrompt}\n\n${agent ? agent.prompt : ''}`, + system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`, tools, model: registeredModel.model, prompt: userPrompt, @@ -276,6 +257,14 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); }, }); + const attemptedToolNames = this.extractAttemptedToolNames( + textResponse.steps, + ); + + this.logger.log( + `Workflow agent model response: agentId=${agent?.id ?? 'none'} modelId=${registeredModel.modelId} finishReason=${textResponse.finishReason} stepCount=${textResponse.steps.length} attemptedToolCalls=[${attemptedToolNames.join(', ')}]`, + ); + const cacheCreationTokens = extractCacheCreationTokensFromSteps( textResponse.steps, ); @@ -331,6 +320,23 @@ ${tools.map((tool) => `- \`${tool.name}\``).join('\n')}`); throw error; } + const errorDetails = + typeof error === 'object' && error !== null + ? { + name: 'name' in error ? error.name : undefined, + message: 'message' in error ? error.message : undefined, + statusCode: + 'statusCode' in error ? error.statusCode : undefined, + responseBody: + 'responseBody' in error ? error.responseBody : undefined, + cause: 'cause' in error ? error.cause : undefined, + } + : { message: String(error) }; + + this.logger.error( + `Workflow agent execution failed: agentId=${agent?.id ?? 'none'} modelId=${registeredModel?.modelId ?? 'unknown'} savedAgentRoleId=${effectiveAgentPermissions?.agentRoleId ?? 'none'} toolCount=${generatedToolNames.length} error=${JSON.stringify(errorDetails)}`, + ); + throw new AiException( error instanceof Error ? error.message : 'Agent execution failed', AiExceptionCode.AGENT_EXECUTION_FAILED, 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 31f5a3126a4..c7829761fbd 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,21 +1,15 @@ import { type StepResult, type ToolSet } from 'ai'; -// Shared by billing and workflow execution logging because both treat these -// as provider-native search tool calls. -export const NATIVE_SEARCH_TOOL_NAMES = new Set(['web_search', 'x_search']); +const WEB_SEARCH_TOOL_NAME = 'web_search'; export const countNativeWebSearchCallsFromSteps = ( steps: StepResult[], -): number => { - let searchCallCount = 0; - - for (const step of steps) { - for (const toolCall of step.toolCalls) { - if (NATIVE_SEARCH_TOOL_NAMES.has(toolCall.toolName)) { - searchCallCount += 1; - } - } - } - - return searchCallCount; -}; +): number => + steps.reduce( + (count, step) => + count + + step.toolCalls.filter( + (toolCall) => toolCall.toolName === WEB_SEARCH_TOOL_NAME, + ).length, + 0, + ); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 88a08cda866..4c7b5e2a916 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -23,15 +23,16 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; -import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type'; import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util'; -import { LazyToolRuntimeService } from 'src/engine/core-modules/tool-provider/services/lazy-tool-runtime.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { + createExecuteToolTool, + createLearnToolsTool, createLoadSkillTool, + EXECUTE_TOOL_TOOL_NAME, + LEARN_TOOLS_TOOL_NAME, LOAD_SKILL_TOOL_NAME, } from 'src/engine/core-modules/tool-provider/tools'; -import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; @@ -54,10 +55,10 @@ import { import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; import { AiModelRegistryService, - type RegisteredAiModel, } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; import { type AiModelConfig } from 'src/engine/metadata-modules/ai/ai-models/types/ai-model-config.type'; +import { AiModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service'; +import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; export type ChatExecutionOptions = { @@ -82,7 +83,6 @@ export class ChatExecutionService { private readonly logger = new Logger(ChatExecutionService.name); constructor( - private readonly lazyToolRuntimeService: LazyToolRuntimeService, private readonly toolRegistry: ToolRegistryService, private readonly skillService: SkillService, private readonly aiModelRegistryService: AiModelRegistryService, @@ -92,7 +92,7 @@ export class ChatExecutionService { private readonly codeInterpreterService: CodeInterpreterService, private readonly systemPromptBuilder: SystemPromptBuilderService, private readonly exceptionHandlerService: ExceptionHandlerService, - private readonly sdkProviderFactory: SdkProviderFactoryService, + private readonly aiModelConfigService: AiModelConfigService, private readonly messagePruningService: MessagePruningService, private readonly webSearchService: WebSearchService, ) {} @@ -114,12 +114,9 @@ export class ChatExecutionService { workspace.id, ); - // Regular AI chat is not executing a saved agent, so agent-specific - // capability toggles should only apply in AgentAsyncExecutorService. - const toolProviderContext: ToolProviderContext = { + const toolContext = { workspaceId: workspace.id, roleId, - rolePermissionConfig: { unionOf: [roleId] }, actorContext, userId, userWorkspaceId, @@ -130,17 +127,32 @@ export class ChatExecutionService { ? this.buildContextFromBrowsingContext(workspace, browsingContext) : undefined; - const useNativeSearch = this.webSearchService.shouldUseNativeSearch(); + const toolCatalog = await this.toolRegistry.buildToolIndex( + workspace.id, + roleId, + { userId, userWorkspaceId }, + ); + + const skillCatalog = await this.skillService.findAllFlatSkills( + workspace.id, + ); this.logger.log( - `Web search strategy: ${useNativeSearch ? 'native (provider SDK)' : 'external (EXA)'}`, + `Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`, ); + const useNativeSearch = this.webSearchService.shouldUseNativeSearch(); + const toolNamesToPreload = [ ...COMMON_PRELOAD_TOOLS, ...(useNativeSearch ? [] : ['web_search']), ]; + const preloadedTools = await this.toolRegistry.getToolsByName( + toolNamesToPreload, + toolContext, + ); + const resolvedModelId = modelId ?? workspace.smartModel; this.aiModelRegistryService.validateModelAvailability( @@ -157,38 +169,36 @@ export class ChatExecutionService { registeredModel.modelId, ); - const { tools: nativeSearchTools } = useNativeSearch - ? this.getNativeWebSearchTools(registeredModel) - : { tools: {} }; + const { tools: nativeSearchTools, callableToolNames: searchToolNames } = + this.aiModelConfigService.getChatNativeSearchTools(registeredModel, { + useProviderNativeWebSearch: useNativeSearch, + }); - const preloadedTools = await this.toolRegistry.getToolsByName( - toolNamesToPreload, - toolProviderContext, - ); + // Direct tools: native provider tools + preloaded tools. + // These are callable directly AND as fallback through execute_tool. + const directTools: ToolSet = { + ...wrapToolsWithOutputSerialization(preloadedTools), + ...nativeSearchTools, + }; - const toolRuntime = await this.lazyToolRuntimeService.buildToolRuntime({ - context: toolProviderContext, - directTools: { - ...wrapToolsWithOutputSerialization(preloadedTools), - ...nativeSearchTools, - }, - }); - - const toolCatalog = toolRuntime.toolCatalog; - const skillCatalog = await this.skillService.findAllFlatSkills( - workspace.id, - ); - - this.logger.log( - `Built tool catalog with ${toolCatalog.length} tools, ${skillCatalog.length} skills available`, - ); - - const preloadedToolNames = toolRuntime.directToolNames; + const preloadedToolNames = [ + ...Object.keys(preloadedTools), + ...searchToolNames, + ]; // ToolSet is constant for the entire conversation — no mutation. // learn_tools returns schemas as text; execute_tool dispatches to cached tools. const activeTools: ToolSet = { - ...toolRuntime.runtimeTools, + ...directTools, + [LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool( + this.toolRegistry, + toolContext, + ), + [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool( + this.toolRegistry, + toolContext, + directTools, + ), [LOAD_SKILL_TOOL_NAME]: createLoadSkillTool( (skillNames) => this.skillService.findFlatSkillsByNames(skillNames, workspace.id), @@ -444,48 +454,6 @@ export class ChatExecutionService { return context; } - private getNativeWebSearchTools(model: RegisteredAiModel): { - tools: ToolSet; - } { - const empty = { tools: {} }; - const providerName = model.providerName; - - if (!providerName) { - return empty; - } - - switch (model.sdkPackage) { - case AI_SDK_ANTHROPIC: { - const provider = - this.sdkProviderFactory.getRawAnthropicProvider(providerName); - - if (!provider) { - return empty; - } - - return { - tools: { web_search: provider.tools.webSearch_20250305() }, - }; - } - case AI_SDK_BEDROCK: - return empty; - case AI_SDK_OPENAI: { - const provider = - this.sdkProviderFactory.getRawOpenAIProvider(providerName); - - if (!provider) { - return empty; - } - - return { - tools: { web_search: provider.tools.webSearch() }, - }; - } - default: - return empty; - } - } - private async storeExtractedFiles( files: ExtractedFile[], _workspaceId: string, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.spec.ts new file mode 100644 index 00000000000..af26f095a35 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.spec.ts @@ -0,0 +1,89 @@ +import { AiModelConfigService } from './ai-model-config.service'; + +import { + AI_SDK_OPENAI, + AI_SDK_XAI, +} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-sdk-package.const'; +import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { type SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; + +describe('AiModelConfigService', () => { + const createService = ( + sdkProviderFactory: Partial, + ) => new AiModelConfigService(sdkProviderFactory as SdkProviderFactoryService); + + const xSearchTool = { type: 'provider', id: 'xai.x_search', args: {} }; + const webSearchTool = { type: 'provider', id: 'xai.web_search', args: {} }; + + it('keeps x search available for xAI chat when external web search is preferred', () => { + const service = createService({ + getRawXaiProvider: jest.fn().mockReturnValue({ + tools: { + xSearch: jest.fn().mockReturnValue(xSearchTool), + webSearch: jest.fn().mockReturnValue(webSearchTool), + }, + }), + }); + + const result = service.getChatNativeSearchTools( + { + sdkPackage: AI_SDK_XAI, + providerName: 'xai', + } as RegisteredAiModel, + { useProviderNativeWebSearch: false }, + ); + + expect(result).toEqual({ + tools: { + x_search: xSearchTool, + }, + callableToolNames: ['x_search'], + }); + }); + + it('exposes both x search and native web search for xAI chat when enabled', () => { + const service = createService({ + getRawXaiProvider: jest.fn().mockReturnValue({ + tools: { + xSearch: jest.fn().mockReturnValue(xSearchTool), + webSearch: jest.fn().mockReturnValue(webSearchTool), + }, + }), + }); + + const result = service.getChatNativeSearchTools( + { + sdkPackage: AI_SDK_XAI, + providerName: 'xai', + } as RegisteredAiModel, + { useProviderNativeWebSearch: true }, + ); + + expect(result).toEqual({ + tools: { + web_search: webSearchTool, + x_search: xSearchTool, + }, + callableToolNames: ['web_search', 'x_search'], + }); + }); + + it('keeps OpenAI native web search disabled when external search is preferred', () => { + const service = createService({ + getRawOpenAIProvider: jest.fn(), + }); + + const result = service.getChatNativeSearchTools( + { + sdkPackage: AI_SDK_OPENAI, + providerName: 'openai', + } as RegisteredAiModel, + { useProviderNativeWebSearch: false }, + ); + + expect(result).toEqual({ + tools: {}, + callableToolNames: [], + }); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts index e252e997f05..409c59c44e6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-config.service.ts @@ -15,6 +15,13 @@ import { import { type RegisteredAiModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { SdkProviderFactoryService } from 'src/engine/metadata-modules/ai/ai-models/services/sdk-provider-factory.service'; +type ChatNativeSearchTools = { + tools: ToolSet; + callableToolNames: string[]; +}; + +type NativeSearchToolEntry = [string, ToolSet[string]]; + @Injectable() export class AiModelConfigService { constructor(private readonly sdkProviderFactory: SdkProviderFactoryService) {} @@ -37,7 +44,6 @@ export class AiModelConfigService { agent: ToolProviderAgent, options: { useProviderNativeWebSearch: boolean }, ): ToolSet { - const tools: ToolSet = {}; const modelConfiguration = agent.modelConfiguration ?? {}; const isWebSearchEnabledForAgent = isAgentCapabilityEnabled( modelConfiguration, @@ -50,69 +56,27 @@ export class AiModelConfigService { const shouldExposeProviderNativeWebSearch = options.useProviderNativeWebSearch && isWebSearchEnabledForAgent; - switch (model.sdkPackage) { - case AI_SDK_ANTHROPIC: - if (shouldExposeProviderNativeWebSearch) { - const anthropicProvider = model.providerName - ? this.sdkProviderFactory.getRawAnthropicProvider( - model.providerName, - ) - : undefined; + const toolEntries = this.getNativeSearchToolEntries(model, { + exposeWebSearch: shouldExposeProviderNativeWebSearch, + exposeTwitterSearch: isTwitterSearchEnabledForAgent, + }); - if (anthropicProvider) { - tools.web_search = anthropicProvider.tools.webSearch_20250305(); - } - } - break; - case AI_SDK_BEDROCK: { - if (shouldExposeProviderNativeWebSearch) { - const bedrockProvider = model.providerName - ? this.sdkProviderFactory.getRawBedrockProvider(model.providerName) - : undefined; + return Object.fromEntries(toolEntries) as ToolSet; + } - if (bedrockProvider) { - tools.web_search = - bedrockProvider.tools.webSearch_20250305() as ToolSet[string]; - } - } - break; - } - case AI_SDK_OPENAI: - if (shouldExposeProviderNativeWebSearch) { - const openaiProvider = model.providerName - ? this.sdkProviderFactory.getRawOpenAIProvider(model.providerName) - : undefined; + getChatNativeSearchTools( + model: RegisteredAiModel, + options: { useProviderNativeWebSearch: boolean }, + ): ChatNativeSearchTools { + const toolEntries = this.getNativeSearchToolEntries(model, { + exposeWebSearch: options.useProviderNativeWebSearch, + exposeTwitterSearch: model.sdkPackage === AI_SDK_XAI, + }); - if (openaiProvider) { - tools.web_search = openaiProvider.tools.webSearch(); - } - } - break; - case AI_SDK_XAI: - if (!model.providerName) { - break; - } - - const xaiProvider = this.sdkProviderFactory.getRawXaiProvider( - model.providerName, - ); - - if (!xaiProvider) { - break; - } - - if (shouldExposeProviderNativeWebSearch) { - tools.web_search = xaiProvider.tools.webSearch() as ToolSet[string]; - } - - if (isTwitterSearchEnabledForAgent) { - tools.x_search = xaiProvider.tools.xSearch() as ToolSet[string]; - } - - break; - } - - return tools; + return { + tools: Object.fromEntries(toolEntries) as ToolSet, + callableToolNames: toolEntries.map(([toolName]) => toolName), + }; } private getAnthropicProviderOptions( @@ -146,4 +110,98 @@ export class AiModelConfigService { }, }; } + + private getNativeSearchToolEntries( + model: RegisteredAiModel, + options: { + exposeWebSearch: boolean; + exposeTwitterSearch: boolean; + }, + ): NativeSearchToolEntry[] { + if (!model.providerName) { + return []; + } + + switch (model.sdkPackage) { + case AI_SDK_ANTHROPIC: { + if (!options.exposeWebSearch) { + return []; + } + + const anthropicProvider = this.sdkProviderFactory.getRawAnthropicProvider( + model.providerName, + ); + + if (!anthropicProvider) { + return []; + } + + return [['web_search', anthropicProvider.tools.webSearch_20250305()]]; + } + case AI_SDK_BEDROCK: { + if (!options.exposeWebSearch) { + return []; + } + + const bedrockProvider = this.sdkProviderFactory.getRawBedrockProvider( + model.providerName, + ); + + if (!bedrockProvider) { + return []; + } + + return [ + [ + 'web_search', + bedrockProvider.tools.webSearch_20250305() as ToolSet[string], + ], + ]; + } + case AI_SDK_OPENAI: { + if (!options.exposeWebSearch) { + return []; + } + + const openAiProvider = this.sdkProviderFactory.getRawOpenAIProvider( + model.providerName, + ); + + if (!openAiProvider) { + return []; + } + + return [['web_search', openAiProvider.tools.webSearch()]]; + } + case AI_SDK_XAI: { + const xaiProvider = this.sdkProviderFactory.getRawXaiProvider( + model.providerName, + ); + + if (!xaiProvider) { + return []; + } + + const toolEntries: NativeSearchToolEntry[] = []; + + if (options.exposeWebSearch) { + toolEntries.push([ + 'web_search', + xaiProvider.tools.webSearch() as ToolSet[string], + ]); + } + + if (options.exposeTwitterSearch) { + toolEntries.push([ + 'x_search', + xaiProvider.tools.xSearch() as ToolSet[string], + ]); + } + + return toolEntries; + } + default: + return []; + } + } } 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 index 02628a162eb..cd21a2bd450 100644 --- a/packages/twenty-shared/src/ai/constants/agent-capability-defaults.const.ts +++ b/packages/twenty-shared/src/ai/constants/agent-capability-defaults.const.ts @@ -3,9 +3,7 @@ import { type AgentCapability } from '../types/agent-capability.type'; export const AGENT_CAPABILITY_DEFAULTS = { webSearch: true, twitterSearch: false, - // Default-off: unlike webSearch (free via model-native capability), codeInterpreter - // has no free path — E2B bills per execution. Per-agent opt-in prevents silent - // spend. Breaking for self-hosters on CODE_INTERPRETER_TYPE=E2B: existing agents - // must flip the per-agent toggle to restore prior workspace-wide behavior. + // Default-off because code execution is a higher-risk, billable capability. + // Per-agent opt-in keeps existing workspaces from silently widening access. codeInterpreter: false, } satisfies Record; diff --git a/packages/twenty-shared/src/ai/types/agent-capability.type.ts b/packages/twenty-shared/src/ai/types/agent-capability.type.ts index 861fc61688d..2264d1d6d21 100644 --- a/packages/twenty-shared/src/ai/types/agent-capability.type.ts +++ b/packages/twenty-shared/src/ai/types/agent-capability.type.ts @@ -1,3 +1,4 @@ -import { type ModelConfiguration } from './model-configuration.type'; - -export type AgentCapability = keyof ModelConfiguration; +export type AgentCapability = + | 'webSearch' + | 'twitterSearch' + | 'codeInterpreter';