From 223082a4da0441aa3bb99e761380d66390be0edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Sat, 6 Dec 2025 06:51:47 +0100 Subject: [PATCH] refactor(twenty-server): consolidate AI tool provider architecture (#16355) ## Summary Consolidates the AI tool provider architecture by creating a single `ToolProviderService` as the entry point for all tool generation. This removes multiple intermediate services and simplifies the codebase. ## Changes ### New Architecture - **`ToolProviderService`**: Single service for all tool generation with: - `getTools(spec)` - Get tools by category with permissions - `getToolByType(type)` - Get specific tool for workflow execution - **`ToolCategory` enum**: Declarative specification of tool types: - `DATABASE_CRUD` - Record CRUD operations - `ACTION` - HTTP requests, email sending, article search - `WORKFLOW` - Workflow management tools - `METADATA` - Object/field metadata tools - `NATIVE_MODEL` - Model-specific tools (e.g., web search) - **`ToolSpecification` type**: Clean API for requesting tools with permissions ### Removed - `AiToolsModule` - No longer needed - `ToolService` - Logic inlined into ToolProviderService - `ToolAdapterService` - Logic inlined into ToolProviderService - `ToolRegistryService` - Logic inlined into ToolProviderService ### Updated - All consumers (agents, chat, MCP, workflows) now use `ToolProviderService` - Test files updated accordingly ## Stats - **547 insertions, 1146 deletions** (net ~600 lines removed) - 4 services deleted - 1 module deleted ## Testing - [x] Typecheck passes - [x] Lint passes --- .../src/engine/api/mcp/mcp.module.ts | 8 +- .../__tests__/mcp-protocol.service.spec.ts | 27 +- .../api/mcp/services/mcp-metadata.service.ts | 26 +- .../api/mcp/services/mcp-protocol.service.ts | 15 +- .../core-modules/billing/billing.module.ts | 2 - .../engine/core-modules/core-engine.module.ts | 2 - .../constants/workflow-tool-service.token.ts | 7 + .../tool-provider/enums/tool-category.enum.ts | 7 + .../services/tool-provider.service.ts | 352 ++++++++++++++++++ .../tool-provider/tool-provider.module.ts | 37 ++ .../types/tool-specification.type.ts | 16 + .../core-modules/tool/enums/tool-type.enum.ts | 1 + .../tool/services/tool-registry.service.ts | 59 --- .../engine/core-modules/tool/tool.module.ts | 5 +- .../workflow-version-step.resolver.ts | 7 +- .../ai-agent-execution.module.ts | 9 +- .../services/agent-async-executor.service.ts | 70 ++-- .../services/agent-execution.service.ts | 2 +- .../services/agent-tool-generator.service.ts | 160 +------- .../ai-chat-router/ai-chat-router.module.ts | 2 - .../ai/ai-chat/ai-chat.module.ts | 17 +- .../services/chat-tools-provider.service.ts | 106 +----- .../ai/ai-models/ai-models.module.ts | 5 +- .../services/agent-model-config.service.ts | 0 .../ai/ai-tools/ai-tools.module.ts | 44 --- .../__tests__/tool-adapter.service.spec.ts | 172 --------- .../services/__tests__/tool.service.spec.ts | 138 ------- .../ai-tools/services/tool-adapter.service.ts | 54 --- .../ai/ai-tools/services/tool.service.ts | 58 --- .../company.workspace-entity.ts | 1 - .../tool-executor-workflow-action.ts | 42 +-- .../agent-tool.service.integration-spec.ts | 155 ++------ .../agent/utils/agent-tool-test-utils.ts | 151 +------- 33 files changed, 588 insertions(+), 1169 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/constants/workflow-tool-service.token.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent-execution => ai-models}/services/agent-model-config.service.ts (100%) delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/ai-tools.module.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool-adapter.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool.service.spec.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service.ts delete mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool.service.ts diff --git a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts index 765aebf006..47cdf0efa7 100644 --- a/packages/twenty-server/src/engine/api/mcp/mcp.module.ts +++ b/packages/twenty-server/src/engine/api/mcp/mcp.module.ts @@ -9,9 +9,7 @@ import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-exe import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module'; -import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; -import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; -import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -19,14 +17,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ @Module({ imports: [ TypeOrmModule.forFeature([RoleEntity]), - AiToolsModule, TokenModule, WorkspaceCacheStorageModule, FeatureFlagModule, MetricsModule, UserRoleModule, - ObjectMetadataModule, - FieldMetadataModule, + ToolProviderModule, ], controllers: [McpCoreController, McpMetadataController], exports: [McpProtocolService], diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts index 46be03c8bf..052b8b189f 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts @@ -10,8 +10,8 @@ import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.ser import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; @@ -19,7 +19,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role describe('McpProtocolService', () => { let service: McpProtocolService; let featureFlagService: jest.Mocked; - let toolService: jest.Mocked; + let toolProviderService: jest.Mocked; let userRoleService: jest.Mocked; let mcpToolExecutorService: jest.Mocked; @@ -34,8 +34,8 @@ describe('McpProtocolService', () => { isFeatureEnabled: jest.fn(), }; - const mockToolService = { - listTools: jest.fn(), + const mockToolProviderService = { + getTools: jest.fn(), }; const mockUserRoleService = { @@ -60,8 +60,8 @@ describe('McpProtocolService', () => { useValue: mockFeatureFlagService, }, { - provide: ToolService, - useValue: mockToolService, + provide: ToolProviderService, + useValue: mockToolProviderService, }, { provide: UserRoleService, @@ -82,7 +82,7 @@ describe('McpProtocolService', () => { service = module.get(McpProtocolService); featureFlagService = module.get(FeatureFlagService); - toolService = module.get(ToolService); + toolProviderService = module.get(ToolProviderService); userRoleService = module.get(UserRoleService); mcpToolExecutorService = module.get(McpToolExecutorService); }); @@ -221,7 +221,7 @@ describe('McpProtocolService', () => { testTool: mockTool, }; - toolService.listTools.mockResolvedValue(mockToolsMap); + toolProviderService.getTools.mockResolvedValue(mockToolsMap); const mockToolCallResponse = { id: '123', @@ -275,7 +275,7 @@ describe('McpProtocolService', () => { testTool: mockTool, }; - toolService.listTools.mockResolvedValue(mockToolsMap); + toolProviderService.getTools.mockResolvedValue(mockToolsMap); const mockToolCallResponse = { id: '123', @@ -309,10 +309,7 @@ describe('McpProtocolService', () => { }); expect(result).toEqual(mockToolCallResponse); - expect(toolService.listTools).toHaveBeenCalledWith( - { unionOf: [mockAdminRoleId] }, - mockWorkspace.id, - ); + expect(toolProviderService.getTools).toHaveBeenCalled(); }); it('should handle tools listing', async () => { @@ -326,7 +323,7 @@ describe('McpProtocolService', () => { }, }; - toolService.listTools.mockResolvedValue(mockToolsMap); + toolProviderService.getTools.mockResolvedValue(mockToolsMap); const mockToolsListingResponse = { id: '123', @@ -392,7 +389,7 @@ describe('McpProtocolService', () => { it('should handle error when tool is not found', async () => { featureFlagService.isFeatureEnabled.mockResolvedValue(true); userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId); - toolService.listTools.mockResolvedValue({}); + toolProviderService.getTools.mockResolvedValue({}); mcpToolExecutorService.handleToolCall.mockRejectedValue( new HttpException( diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts index 9d7d2b071d..6ee0f8477c 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-metadata.service.ts @@ -8,16 +8,15 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'; import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory'; -import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; @Injectable() export class MCPMetadataService { constructor( private readonly featureFlagService: FeatureFlagService, - private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory, - private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory, + private readonly toolProvider: ToolProviderService, private readonly metricsService: MetricsService, ) {} @@ -50,18 +49,19 @@ export class MCPMetadataService { }); } - getTools(workspaceId: string): ToolSet { - return { - ...this.objectMetadataToolsFactory.generateTools(workspaceId), - ...this.fieldMetadataToolsFactory.generateTools(workspaceId), - }; + async getTools(workspaceId: string): Promise { + return this.toolProvider.getTools({ + workspaceId, + categories: [ToolCategory.METADATA], + wrapWithErrorContext: false, + }); } async handleToolCall( request: Request, workspaceId: string, ): Promise[1]> { - const tools = this.getTools(workspaceId); + const tools = await this.getTools(workspaceId); const toolName = request.body.params.name as keyof typeof tools; const tool = tools[toolName]; @@ -99,8 +99,8 @@ export class MCPMetadataService { }; } - listTools(request: Request, workspaceId: string) { - const tools = this.getTools(workspaceId); + async listTools(request: Request, workspaceId: string) { + const tools = await this.getTools(workspaceId); const toolsArray = Object.entries(tools) .filter(([, def]) => !!def.inputSchema) @@ -167,7 +167,7 @@ export class MCPMetadataService { } if (request.body.method === 'tools/list') { - return this.listTools(request, workspace.id); + return await this.listTools(request, workspace.id); } if (request.body.method === 'prompts/list') { 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 855213c419..5188e2ef60 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 @@ -8,8 +8,9 @@ import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-exe import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service'; import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role'; @@ -18,7 +19,7 @@ import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata export class McpProtocolService { constructor( private readonly featureFlagService: FeatureFlagService, - private readonly toolService: ToolService, + private readonly toolProvider: ToolProviderService, private readonly userRoleService: UserRoleService, private readonly mcpToolExecutorService: McpToolExecutorService, @InjectRepository(RoleEntity) @@ -128,10 +129,12 @@ export class McpProtocolService { apiKey, ); - const toolSet = await this.toolService.listTools( - { unionOf: [roleId] }, - workspace.id, - ); + const toolSet = await this.toolProvider.getTools({ + workspaceId: workspace.id, + categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION], + rolePermissionConfig: { unionOf: [roleId] }, + wrapWithErrorContext: false, + }); if (method === 'tools/call' && params) { return await this.mcpToolExecutorService.handleToolCall( diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts index b14784cd71..df58903ae0 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.module.ts @@ -35,7 +35,6 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; -import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; @@ -47,7 +46,6 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi PermissionsModule, AiBillingModule, AiModelsModule, - AiToolsModule, WorkspaceDomainsModule, TypeOrmModule.forFeature([ BillingSubscriptionEntity, diff --git a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts index 5e154c6ae0..73b2924f34 100644 --- a/packages/twenty-server/src/engine/core-modules/core-engine.module.ts +++ b/packages/twenty-server/src/engine/core-modules/core-engine.module.ts @@ -56,7 +56,6 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; -import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; import { RoleModule } from 'src/engine/metadata-modules/role/role.module'; import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module'; import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module'; @@ -130,7 +129,6 @@ import { FileModule } from './file/file.module'; }), CacheStorageModule, AiModelsModule, - AiToolsModule, AiBillingModule, ServerlessModule.forRootAsync({ useFactory: serverlessModuleFactory, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/workflow-tool-service.token.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/workflow-tool-service.token.ts new file mode 100644 index 0000000000..c88f8eb903 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/workflow-tool-service.token.ts @@ -0,0 +1,7 @@ +// Injection token for WorkflowToolWorkspaceService to break circular dependency: +// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule +// -> WorkflowRunnerModule -> WorkflowExecutorModule -> AiAgentActionModule +// -> AiAgentExecutionModule -> ToolProviderModule +export const WORKFLOW_TOOL_SERVICE_TOKEN = Symbol( + 'WORKFLOW_TOOL_SERVICE_TOKEN', +); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts new file mode 100644 index 0000000000..402facffcc --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/enums/tool-category.enum.ts @@ -0,0 +1,7 @@ +export enum ToolCategory { + DATABASE_CRUD = 'DATABASE_CRUD', + ACTION = 'ACTION', + WORKFLOW = 'WORKFLOW', + METADATA = 'METADATA', + NATIVE_MODEL = 'NATIVE_MODEL', +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts new file mode 100644 index 0000000000..f378ed4e80 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts @@ -0,0 +1,352 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; + +import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; +import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; +import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; +import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory'; +import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service'; +import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { type ToolSpecification } from 'src/engine/core-modules/tool-provider/types/tool-specification.type'; +import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; +import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; +import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory'; +import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; +import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +// Type-only import to avoid circular dependency at file level +import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; + +// Tool definition with optional permission flag +type ActionTool = { + tool: Tool; + flag?: PermissionFlagType; +}; + +@Injectable() +export class ToolProviderService { + private readonly logger = new Logger(ToolProviderService.name); + private readonly actionTools: Map; + + constructor( + // Action tools (individual tools) + private readonly httpTool: HttpTool, + private readonly sendEmailTool: SendEmailTool, + private readonly searchArticlesTool: SearchArticlesTool, + // Database CRUD tools + private readonly perObjectToolGenerator: PerObjectToolGeneratorService, + private readonly createRecordService: CreateRecordService, + private readonly updateRecordService: UpdateRecordService, + private readonly deleteRecordService: DeleteRecordService, + private readonly findRecordsService: FindRecordsService, + // Workflow tools - optional to avoid circular dependency with WorkflowExecutorModule. + // When used from workflow context, this will be null (and workflow tools aren't + // needed anyway since agents in workflows shouldn't create other workflows). + // When used from chat context, WorkflowToolsModule provides this service. + @Optional() + @Inject(WORKFLOW_TOOL_SERVICE_TOKEN) + private readonly workflowToolService: WorkflowToolWorkspaceService | null, + // Metadata tools + private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory, + private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory, + // Native model tools + private readonly agentModelConfigService: AgentModelConfigService, + private readonly aiModelRegistryService: AiModelRegistryService, + // Permissions + private readonly permissionsService: PermissionsService, + ) { + this.actionTools = new Map([ + [ + ToolType.HTTP_REQUEST, + { + tool: this.httpTool, + flag: PermissionFlagType.HTTP_REQUEST_TOOL, + }, + ], + [ + ToolType.SEND_EMAIL, + { + tool: this.sendEmailTool, + flag: PermissionFlagType.SEND_EMAIL_TOOL, + }, + ], + [ + ToolType.SEARCH_ARTICLES, + { + tool: this.searchArticlesTool, + // No permission flag - available to all agents + }, + ], + ]); + } + + // Get a specific tool by type (used by workflow executor) + getToolByType(toolType: ToolType): Tool { + const actionTool = this.actionTools.get(toolType); + + if (!actionTool) { + throw new Error(`Unknown tool type: ${toolType}`); + } + + return actionTool.tool; + } + + async getTools(spec: ToolSpecification): Promise { + const tools: ToolSet = {}; + + for (const category of spec.categories) { + const categoryTools = await this.getToolsForCategory(category, spec); + + Object.assign(tools, categoryTools); + } + + this.logger.log( + `Generated ${Object.keys(tools).length} tools for categories: [${spec.categories.join(', ')}]`, + ); + + if (spec.wrapWithErrorContext) { + return this.wrapToolsWithErrorContext(tools); + } + + return tools; + } + + private async getToolsForCategory( + category: ToolCategory, + spec: ToolSpecification, + ): Promise { + switch (category) { + case ToolCategory.DATABASE_CRUD: + return this.getDatabaseTools(spec); + case ToolCategory.ACTION: + return this.getActionTools(spec); + case ToolCategory.WORKFLOW: + return this.getWorkflowTools(spec); + case ToolCategory.METADATA: + return this.getMetadataTools(spec); + case ToolCategory.NATIVE_MODEL: + return this.getNativeModelTools(spec); + default: + return {}; + } + } + + private async getDatabaseTools(spec: ToolSpecification): Promise { + if (!spec.rolePermissionConfig) { + return {}; + } + + const factory = createDirectRecordToolsFactory({ + createRecordService: this.createRecordService, + updateRecordService: this.updateRecordService, + deleteRecordService: this.deleteRecordService, + findRecordsService: this.findRecordsService, + }); + + return this.perObjectToolGenerator.generate( + { + workspaceId: spec.workspaceId, + rolePermissionConfig: spec.rolePermissionConfig, + actorContext: spec.actorContext, + }, + [factory], + spec.toolHints, + ); + } + + private async getActionTools(spec: ToolSpecification): Promise { + const tools: ToolSet = {}; + + for (const [toolType, { tool, flag }] of this.actionTools) { + if (!flag) { + // No permission flag - available to all + tools[toolType.toLowerCase()] = { + description: tool.description, + inputSchema: tool.inputSchema, + execute: async (parameters: { input: ToolInput }) => + tool.execute(parameters.input), + }; + } else if (spec.rolePermissionConfig && spec.workspaceId) { + const hasPermission = await this.permissionsService.hasToolPermission( + spec.rolePermissionConfig, + spec.workspaceId, + flag, + ); + + if (hasPermission) { + tools[toolType.toLowerCase()] = { + description: tool.description, + inputSchema: tool.inputSchema, + execute: async (parameters: { input: ToolInput }) => + tool.execute(parameters.input), + }; + } + } + } + + return tools; + } + + private async getWorkflowTools(spec: ToolSpecification): Promise { + // Workflow tools are optional - not available when called from workflow context + // to avoid circular dependencies (agents in workflows shouldn't create workflows) + if (!this.workflowToolService) { + return {}; + } + + if (!spec.rolePermissionConfig) { + return {}; + } + + const hasWorkflowPermission = + await this.permissionsService.checkRolesPermissions( + spec.rolePermissionConfig, + spec.workspaceId, + PermissionFlagType.WORKFLOWS, + ); + + if (!hasWorkflowPermission) { + return {}; + } + + const workflowTools = this.workflowToolService.generateWorkflowTools( + spec.workspaceId, + spec.rolePermissionConfig, + ); + + const recordStepTools = + await this.workflowToolService.generateRecordStepConfiguratorTools( + spec.workspaceId, + spec.rolePermissionConfig, + spec.toolHints, + ); + + return { ...workflowTools, ...recordStepTools }; + } + + private async getMetadataTools(spec: ToolSpecification): Promise { + if (spec.rolePermissionConfig) { + const hasDataModelPermission = + await this.permissionsService.checkRolesPermissions( + spec.rolePermissionConfig, + spec.workspaceId, + PermissionFlagType.DATA_MODEL, + ); + + if (!hasDataModelPermission) { + return {}; + } + } + + const objectMetadataTools = this.objectMetadataToolsFactory.generateTools( + spec.workspaceId, + ); + + const fieldMetadataTools = this.fieldMetadataToolsFactory.generateTools( + spec.workspaceId, + ); + + return { ...objectMetadataTools, ...fieldMetadataTools }; + } + + private async getNativeModelTools(spec: ToolSpecification): Promise { + if (!spec.agent) { + return {}; + } + + const registeredModel = + await this.aiModelRegistryService.resolveModelForAgent(spec.agent); + + return this.agentModelConfigService.getNativeModelTools( + registeredModel, + spec.agent, + ); + } + + private wrapToolsWithErrorContext(tools: ToolSet): ToolSet { + const wrappedTools: ToolSet = {}; + + for (const [toolName, tool] of Object.entries(tools)) { + if (!tool.execute) { + wrappedTools[toolName] = tool; + continue; + } + + const originalExecute = tool.execute; + + wrappedTools[toolName] = { + ...tool, + execute: async (...args: Parameters) => { + try { + return await originalExecute(...args); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + return { + success: false, + error: { + message: errorMessage, + tool: toolName, + suggestion: this.generateErrorSuggestion( + toolName, + errorMessage, + ), + }, + }; + } + }, + }; + } + + return wrappedTools; + } + + private generateErrorSuggestion( + toolName: string, + errorMessage: string, + ): string { + const lowerError = errorMessage.toLowerCase(); + + if ( + lowerError.includes('not found') || + lowerError.includes('does not exist') + ) { + return 'Verify the ID or name exists with a search query first'; + } + + if ( + lowerError.includes('permission') || + lowerError.includes('forbidden') || + lowerError.includes('unauthorized') + ) { + return 'This operation requires elevated permissions or a different role'; + } + + if (lowerError.includes('invalid') || lowerError.includes('validation')) { + return 'Check the tool schema for valid parameter formats and types'; + } + + if ( + lowerError.includes('duplicate') || + lowerError.includes('already exists') + ) { + return 'A record with this identifier already exists. Try updating instead of creating'; + } + + if (lowerError.includes('required') || lowerError.includes('missing')) { + return 'Required fields are missing. Check which fields are mandatory for this operation'; + } + + return 'Try adjusting the parameters or using a different approach'; + } +} 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 new file mode 100644 index 0000000000..037a562cf5 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -0,0 +1,37 @@ +import { forwardRef, Module } from '@nestjs/common'; + +import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; +import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module'; +import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; +import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; +import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; + +import { ToolProviderService } from './services/tool-provider.service'; + +// NOTE: This module does NOT import WorkflowToolsModule to avoid circular dependency: +// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule +// -> WorkflowRunnerModule -> WorkflowExecutorModule -> AiAgentActionModule +// -> AiAgentExecutionModule -> ToolProviderModule +// +// Instead, WorkflowToolWorkspaceService is an optional dependency that must be +// provided by the importing module (e.g., AiChatModule imports WorkflowToolsModule). + +@Module({ + imports: [ + ToolModule, + ToolGeneratorModule, + RecordCrudModule, + AiModelsModule, + // forwardRef needed: AiAgentExecutionModule imports ToolProviderModule + forwardRef(() => AiAgentExecutionModule), + ObjectMetadataModule, + FieldMetadataModule, + PermissionsModule, + ], + providers: [ToolProviderService], + exports: [ToolProviderService], +}) +export class ToolProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts new file mode 100644 index 0000000000..43b2ef1c17 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts @@ -0,0 +1,16 @@ +import { type ActorMetadata } from 'twenty-shared/types'; + +import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; +import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type ToolSpecification = { + workspaceId: string; + categories: ToolCategory[]; + rolePermissionConfig?: RolePermissionConfig; + actorContext?: ActorMetadata; + agent?: FlatAgentWithRoleId | null; + toolHints?: ToolHints; + wrapWithErrorContext?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts b/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts index 2c7e8c738e..7dc74ca351 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/enums/tool-type.enum.ts @@ -1,4 +1,5 @@ export enum ToolType { HTTP_REQUEST = 'HTTP_REQUEST', SEND_EMAIL = 'SEND_EMAIL', + SEARCH_ARTICLES = 'SEARCH_ARTICLES', } diff --git a/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts deleted file mode 100644 index ee500c1ec8..0000000000 --- a/packages/twenty-server/src/engine/core-modules/tool/services/tool-registry.service.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; -import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; -import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; -import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type'; -import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; -import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; - -@Injectable() -export class ToolRegistryService { - private readonly toolFactories: Map Tool>; - - constructor( - private readonly sendEmailTool: SendEmailTool, - private readonly twentyConfigService: TwentyConfigService, - ) { - this.toolFactories = new Map Tool>([ - [ - ToolType.HTTP_REQUEST, - () => { - const httpTool = new HttpTool(twentyConfigService); - - return { - description: httpTool.description, - inputSchema: httpTool.inputSchema, - execute: (params) => httpTool.execute(params), - flag: PermissionFlagType.HTTP_REQUEST_TOOL, - }; - }, - ], - [ - ToolType.SEND_EMAIL, - () => ({ - description: this.sendEmailTool.description, - inputSchema: this.sendEmailTool.inputSchema, - execute: (params) => - this.sendEmailTool.execute(params as SendEmailInput), - flag: PermissionFlagType.SEND_EMAIL_TOOL, - }), - ], - ]); - } - - getTool(toolType: ToolType): Tool { - const factory = this.toolFactories.get(toolType); - - if (!factory) { - throw new Error(`Unknown tool type: ${toolType}`); - } - - return factory(); - } - - getAllToolTypes(): ToolType[] { - return Array.from(this.toolFactories.keys()); - } -} diff --git a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts index 08b8922ea8..56b8c31a8a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tool.module.ts @@ -3,7 +3,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileModule } from 'src/engine/core-modules/file/file.module'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; @@ -15,7 +14,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo TypeOrmModule.forFeature([FileEntity]), FileModule, ], - providers: [HttpTool, SendEmailTool, SearchArticlesTool, ToolRegistryService], - exports: [ToolRegistryService], + providers: [HttpTool, SendEmailTool, SearchArticlesTool], + exports: [HttpTool, SendEmailTool, SearchArticlesTool], }) export class ToolModule {} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts index 7929fd687b..46829b6367 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version-step.resolver.ts @@ -5,8 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; -import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto'; import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto'; @@ -47,7 +46,7 @@ export class WorkflowVersionStepResolver { private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, - private readonly toolRegistryService: ToolRegistryService, + private readonly httpTool: HttpTool, private readonly featureFlagService: FeatureFlagService, ) {} @@ -153,7 +152,7 @@ export class WorkflowVersionStepResolver { @Args('input') { url, method, headers, body }: TestHttpRequestInput, ): Promise { - return this.toolRegistryService.getTool(ToolType.HTTP_REQUEST).execute({ + return this.httpTool.execute({ url, method, headers, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts index 304b5a22a8..05fa094884 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts @@ -1,13 +1,13 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; +import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; -import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; @@ -19,7 +19,6 @@ import { AgentTurnEntity } from './entities/agent-turn.entity'; import { AgentActorContextService } from './services/agent-actor-context.service'; import { AgentAsyncExecutorService } from './services/agent-async-executor.service'; import { AgentExecutionService } from './services/agent-execution.service'; -import { AgentModelConfigService } from './services/agent-model-config.service'; import { AgentPlanExecutorService } from './services/agent-plan-executor.service'; import { AgentToolGeneratorService } from './services/agent-tool-generator.service'; @@ -27,13 +26,13 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi imports: [ AiBillingModule, AiModelsModule, - AiToolsModule, AiAgentModule, WorkspaceDomainsModule, UserWorkspaceModule, UserRoleModule, PermissionsModule, WorkspaceCacheModule, + forwardRef(() => ToolProviderModule), TypeOrmModule.forFeature([ AgentEntity, AgentMessageEntity, @@ -46,7 +45,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi AgentAsyncExecutorService, AgentExecutionService, AgentToolGeneratorService, - AgentModelConfigService, AgentActorContextService, AgentPlanExecutorService, ], @@ -56,7 +54,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi AgentPlanExecutorService, AgentToolGeneratorService, AgentActorContextService, - AgentModelConfigService, TypeOrmModule.forFeature([ AgentMessageEntity, AgentMessagePartEntity, 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 9ebc0f2e3e..0464e4d109 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 @@ -11,6 +11,8 @@ import { import { type ActorMetadata } from 'twenty-shared/types'; import { Repository } from 'typeorm'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; import { AgentException, @@ -22,12 +24,9 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag 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 { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.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 { AgentModelConfigService } from './agent-model-config.service'; +import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; // Agent execution within workflows uses database and action tools only. // Workflow tools are intentionally excluded to avoid circular dependencies @@ -39,8 +38,7 @@ export class AgentAsyncExecutorService { constructor( private readonly aiModelRegistryService: AiModelRegistryService, private readonly agentModelConfigService: AgentModelConfigService, - private readonly toolAdapterService: ToolAdapterService, - private readonly toolService: ToolService, + private readonly toolProvider: ToolProviderService, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, ) {} @@ -63,12 +61,11 @@ export class AgentAsyncExecutorService { return []; } - private async getToolsForWorkflowExecution( + private async getEffectiveRolePermissionConfig( agentId: string, workspaceId: string, - actorContext?: ActorMetadata, rolePermissionConfig?: RolePermissionConfig, - ): Promise { + ): Promise { const roleTarget = await this.roleTargetRepository.findOne({ where: { agentId, @@ -80,37 +77,15 @@ export class AgentAsyncExecutorService { const agentRoleId = roleTarget?.roleId; const configRoleIds = this.extractRoleIds(rolePermissionConfig); - // Combine role IDs from config and agent const allRoleIds = agentRoleId ? [...new Set([...configRoleIds, agentRoleId])] : configRoleIds; if (allRoleIds.length === 0) { - // No role context - return basic action tools only - return this.toolAdapterService.getTools(); + return undefined; } - const effectiveRoleContext: RolePermissionConfig = { - intersectionOf: allRoleIds, - }; - - // Get database CRUD tools - const databaseTools = await this.toolService.listTools( - effectiveRoleContext, - workspaceId, - actorContext, - ); - - // Get action tools (send email, http request, etc.) - const actionTools = await this.toolAdapterService.getTools( - effectiveRoleContext, - workspaceId, - ); - - return { - ...databaseTools, - ...actionTools, - }; + return { intersectionOf: allRoleIds }; } async executeAgent({ @@ -132,23 +107,28 @@ export class AgentAsyncExecutorService { let providerOptions = {}; if (agent) { - tools = await this.getToolsForWorkflowExecution( + const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig( agent.id, agent.workspaceId, - actorContext, rolePermissionConfig, ); - // Add native model tools (web search, etc.) if configured - const nativeModelTools = - this.agentModelConfigService.getNativeModelTools( - registeredModel, - agent as unknown as Parameters< - typeof this.agentModelConfigService.getNativeModelTools - >[1], - ); - - tools = { ...tools, ...nativeModelTools }; + // Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only + // Workflow tools are excluded to prevent circular dependencies + tools = await this.toolProvider.getTools({ + workspaceId: agent.workspaceId, + categories: [ + ToolCategory.DATABASE_CRUD, + ToolCategory.ACTION, + ToolCategory.NATIVE_MODEL, + ], + rolePermissionConfig: effectiveRoleConfig, + actorContext, + agent: agent as unknown as Parameters< + typeof this.toolProvider.getTools + >[0]['agent'], + wrapWithErrorContext: false, + }); providerOptions = this.agentModelConfigService.getProviderOptions( registeredModel, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts index 4f7ced28b0..34277e7088 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts @@ -32,9 +32,9 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; import { AgentActorContextService } from './agent-actor-context.service'; -import { AgentModelConfigService } from './agent-model-config.service'; import { AgentToolGeneratorService } from './agent-tool-generator.service'; // Re-export for backward compatibility diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts index a0da1330ad..bcf3859a85 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts @@ -1,30 +1,22 @@ import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { type ToolSet } from 'ai'; -import { Repository } from 'typeorm'; import type { ActorMetadata } from 'twenty-shared/types'; -import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import type { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; -import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent'; @Injectable() export class AgentToolGeneratorService { private readonly logger = new Logger(AgentToolGeneratorService.name); - constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, - private readonly toolAdapterService: ToolAdapterService, - private readonly toolService: ToolService, - private readonly searchArticlesTool: SearchArticlesTool, - ) {} + constructor(private readonly toolProvider: ToolProviderService) {} + // Generates base tools for chat context (DATABASE_CRUD and ACTION) + // Additional tools (WORKFLOW, METADATA) are provided via additionalTools + // from ChatToolsProviderService to avoid circular dependencies async generateToolsForAgent( agentId: string, workspaceId: string, @@ -32,142 +24,24 @@ export class AgentToolGeneratorService { roleIds?: string[], toolHints?: ToolHints, ): Promise { - let tools: ToolSet = {}; - try { - const agent = await this.agentRepository.findOne({ - where: { id: agentId }, - }); - - if (agent?.standardId === HELPER_AGENT.standardId) { - return this.wrapToolsWithErrorContext(this.getHelperAgentTools()); - } - - const actionTools = await this.toolAdapterService.getTools(); - - tools = { ...actionTools }; - - if (!roleIds) { - return this.wrapToolsWithErrorContext(tools); - } - - // Workflow tools are NOT generated here to avoid circular dependencies - // They are provided via additionalTools from ChatToolsProviderService in the chat context - - const databaseTools = await this.toolService.listTools( - { intersectionOf: roleIds }, + return await this.toolProvider.getTools({ workspaceId, + categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION], + rolePermissionConfig: roleIds ? { intersectionOf: roleIds } : undefined, actorContext, toolHints, - ); - - tools = { ...tools, ...databaseTools }; - - const roleActionTools = await this.toolAdapterService.getTools( - { intersectionOf: roleIds }, - workspaceId, - ); - - tools = { ...tools, ...roleActionTools }; + wrapWithErrorContext: true, + }); } catch (toolError) { + const errorMessage = + toolError instanceof Error ? toolError.message : 'Unknown error'; + this.logger.warn( - `Failed to generate tools for agent ${agentId}: ${toolError.message}. Proceeding without tools.`, + `Failed to generate tools for agent ${agentId}: ${errorMessage}. Proceeding without tools.`, ); + + return {}; } - - return this.wrapToolsWithErrorContext(tools); - } - - private getHelperAgentTools(): ToolSet { - const tools: ToolSet = { - search_articles: { - description: this.searchArticlesTool.description, - inputSchema: this.searchArticlesTool.inputSchema, - execute: async (params) => - this.searchArticlesTool.execute(params.input), - }, - }; - - this.logger.log('Generated search_articles tool for Helper agent'); - - return tools; - } - - private wrapToolsWithErrorContext(tools: ToolSet): ToolSet { - const wrappedTools: ToolSet = {}; - - for (const [toolName, tool] of Object.entries(tools)) { - if (!tool.execute) { - wrappedTools[toolName] = tool; - continue; - } - - const originalExecute = tool.execute; - - wrappedTools[toolName] = { - ...tool, - execute: async (...args: Parameters) => { - try { - return await originalExecute(...args); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - return { - success: false, - error: { - message: errorMessage, - tool: toolName, - suggestion: this.generateErrorSuggestion( - toolName, - errorMessage, - ), - }, - }; - } - }, - }; - } - - return wrappedTools; - } - - private generateErrorSuggestion( - toolName: string, - errorMessage: string, - ): string { - const lowerError = errorMessage.toLowerCase(); - - if ( - lowerError.includes('not found') || - lowerError.includes('does not exist') - ) { - return 'Verify the ID or name exists with a search query first'; - } - - if ( - lowerError.includes('permission') || - lowerError.includes('forbidden') || - lowerError.includes('unauthorized') - ) { - return 'This operation requires elevated permissions or a different role'; - } - - if (lowerError.includes('invalid') || lowerError.includes('validation')) { - return 'Check the tool schema for valid parameter formats and types'; - } - - if ( - lowerError.includes('duplicate') || - lowerError.includes('already exists') - ) { - return 'A record with this identifier already exists. Try updating instead of creating'; - } - - if (lowerError.includes('required') || lowerError.includes('missing')) { - return 'Required fields are missing. Check which fields are mandatory for this operation'; - } - - return 'Try adjusting the parameters or using a different approach'; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts index 5bab3a8ee9..d417d326da 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts @@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; -import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { AiChatRouterService } from './ai-chat-router.service'; @@ -16,7 +15,6 @@ import { AiChatRouterStrategyDeciderService } from './services/ai-chat-router-st imports: [ TypeOrmModule.forFeature([AgentEntity, WorkspaceEntity]), AiModelsModule, - AiToolsModule, ObjectMetadataModule, ], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts index d43d5e5c64..cce74ff789 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts @@ -7,16 +7,17 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; +import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; +import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module'; -import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; -import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module'; import { AgentChatController } from './controllers/agent-chat.controller'; @@ -47,12 +48,9 @@ import { ChatToolsProviderService } from './services/chat-tools-provider.service TokenModule, UserWorkspaceModule, AiBillingModule, - // Provides WorkflowToolWorkspaceService for ChatToolsProviderService - // Workflow tools are only available in chat context, not in workflow executor (to avoid circular deps) + ToolProviderModule, + // WorkflowToolsModule provides workflow tools for chat context WorkflowToolsModule, - // Provides metadata tools factories for ChatToolsProviderService - ObjectMetadataModule, - FieldMetadataModule, ], controllers: [AgentChatController], providers: [ @@ -62,6 +60,11 @@ import { ChatToolsProviderService } from './services/chat-tools-provider.service AgentChatRoutingService, AgentTitleGenerationService, ChatToolsProviderService, + // Provide WorkflowToolWorkspaceService via token for ToolProviderService + { + provide: WORKFLOW_TOOL_SERVICE_TOKEN, + useExisting: WorkflowToolWorkspaceService, + }, ], exports: [ AgentChatService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts index 0831538500..30e99c5bf9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts @@ -1,113 +1,37 @@ -/* eslint-disable @nx/workspace-inject-workspace-repository */ import { Injectable, Logger } from '@nestjs/common'; import { type ToolSet } from 'ai'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory'; -import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; -import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; @Injectable() export class ChatToolsProviderService { private readonly logger = new Logger(ChatToolsProviderService.name); - constructor( - private readonly workflowToolService: WorkflowToolWorkspaceService, - private readonly permissionsService: PermissionsService, - private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory, - private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory, - ) {} + constructor(private readonly toolProvider: ToolProviderService) {} - // Consolidates all permission-based tools for the chat context + // Provides additional tools for the chat context (WORKFLOW and METADATA) + // These tools are NOT available in the workflow executor context to prevent circular dependencies + // Base tools (DATABASE_CRUD, ACTION) are provided by AgentToolGeneratorService async getChatTools( workspaceId: string, roleIds: string[], toolHints?: ToolHints, ): Promise { - const [workflowTools, metadataTools] = await Promise.all([ - this.getWorkflowTools(workspaceId, roleIds, toolHints), - this.getMetadataTools(workspaceId, roleIds), - ]); - - const allTools = { ...workflowTools, ...metadataTools }; + const tools = await this.toolProvider.getTools({ + workspaceId, + categories: [ToolCategory.WORKFLOW, ToolCategory.METADATA], + rolePermissionConfig: { intersectionOf: roleIds }, + toolHints, + wrapWithErrorContext: false, + }); this.logger.log( - `Generated ${Object.keys(allTools).length} total chat tools (workflow: ${Object.keys(workflowTools).length}, metadata: ${Object.keys(metadataTools).length})`, + `Generated ${Object.keys(tools).length} additional chat tools (workflow + metadata)`, ); - return allTools; - } - - // Provides workflow-specific tools for the chat context - // These tools are NOT available in the workflow executor context to prevent circular dependencies - private async getWorkflowTools( - workspaceId: string, - roleIds: string[], - toolHints?: ToolHints, - ): Promise { - const rolePermissionConfig = { intersectionOf: roleIds }; - - const hasWorkflowPermission = - await this.permissionsService.checkRolesPermissions( - rolePermissionConfig, - workspaceId, - PermissionFlagType.WORKFLOWS, - ); - - if (!hasWorkflowPermission) { - this.logger.log( - 'User does not have workflow permissions, skipping workflow tools', - ); - - return {}; - } - - const workflowTools = this.workflowToolService.generateWorkflowTools( - workspaceId, - rolePermissionConfig, - ); - - const recordStepTools = - await this.workflowToolService.generateRecordStepConfiguratorTools( - workspaceId, - rolePermissionConfig, - toolHints, - ); - - return { ...workflowTools, ...recordStepTools }; - } - - // Provides metadata tools for managing objects and fields in the data model - private async getMetadataTools( - workspaceId: string, - roleIds: string[], - ): Promise { - const rolePermissionConfig = { intersectionOf: roleIds }; - - const hasDataModelPermission = - await this.permissionsService.checkRolesPermissions( - rolePermissionConfig, - workspaceId, - PermissionFlagType.DATA_MODEL, - ); - - if (!hasDataModelPermission) { - this.logger.log( - 'User does not have data model permissions, skipping metadata tools', - ); - - return {}; - } - - const objectMetadataTools = - this.objectMetadataToolsFactory.generateTools(workspaceId); - - const fieldMetadataTools = - this.fieldMetadataToolsFactory.generateTools(workspaceId); - - return { ...objectMetadataTools, ...fieldMetadataTools }; + return tools; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts index e55a613d7a..316245154d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/ai-models.module.ts @@ -1,11 +1,12 @@ import { Global, Module } from '@nestjs/common'; +import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; import { AiService } from 'src/engine/metadata-modules/ai/ai-models/services/ai.service'; @Global() @Module({ - providers: [AiModelRegistryService, AiService], - exports: [AiModelRegistryService, AiService], + providers: [AiModelRegistryService, AiService, AgentModelConfigService], + exports: [AiModelRegistryService, AiService, AgentModelConfigService], }) export class AiModelsModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-model-config.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/ai-tools.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/ai-tools.module.ts deleted file mode 100644 index d8d3969bd8..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/ai-tools.module.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Global, Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; -import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; -import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; -import { FileModule } from 'src/engine/core-modules/file/file.module'; -import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module'; -import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module'; -import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; -import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; -import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; -import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; -import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; -import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; -import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; -import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; -import { MessagingModule } from 'src/modules/messaging/messaging.module'; - -@Global() -@Module({ - imports: [ - TypeOrmModule.forFeature([RoleEntity, FileEntity]), - FileModule, - TokenModule, - FeatureFlagModule, - RecordCrudModule, - ToolGeneratorModule, - WorkspaceManyOrAllFlatEntityMapsCacheModule, - WorkspaceCacheStorageModule, - UserRoleModule, - TwentyORMModule, - MessagingModule, - PermissionsModule, - ToolModule, - WorkspaceCacheModule, - ], - providers: [ToolService, ToolAdapterService, SearchArticlesTool], - exports: [ToolService, ToolAdapterService, SearchArticlesTool], -}) -export class AiToolsModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool-adapter.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool-adapter.service.spec.ts deleted file mode 100644 index 5b14943097..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool-adapter.service.spec.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { jsonSchema } from 'ai'; - -import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; -import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; -import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; -import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service'; -import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; - -const createMockToolRegistry = () => ({ - getAllToolTypes: jest.fn(), - getTool: jest.fn(), -}); - -const createMockPermissions = () => ({ - hasToolPermission: jest.fn< - Promise, - [RolePermissionConfig, string, PermissionFlagType] - >(), -}); - -describe('ToolAdapterService', () => { - let mockRegistry: ReturnType; - let mockPermissions: ReturnType; - let service: ToolAdapterService; - - // Shared tools - const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({ - success: true, - message: 'Tool executed successfully', - result: { echoed: input }, - })); - const unflaggedTool: Tool = { - description: 'HTTP Request tool', - inputSchema: jsonSchema({ type: 'object', properties: {} }), - execute: unflaggedToolExecute, - }; - - const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({ - success: true, - message: 'Tool executed successfully', - result: { sent: input }, - })); - const flaggedTool: Tool = { - description: 'Send Email tool', - inputSchema: jsonSchema({ type: 'object', properties: {} }), - execute: flaggedToolExecute, - flag: PermissionFlagType.SEND_EMAIL_TOOL, - }; - - beforeEach(async () => { - jest.clearAllMocks(); - mockRegistry = createMockToolRegistry(); - mockPermissions = createMockPermissions(); - - // Setup mock tool responses - mockRegistry.getAllToolTypes.mockReturnValue([ - ToolType.HTTP_REQUEST, - ToolType.SEND_EMAIL, - ]); - mockRegistry.getTool.mockImplementation((type: ToolType) => { - if (type === ToolType.HTTP_REQUEST) return unflaggedTool; - if (type === ToolType.SEND_EMAIL) return flaggedTool; - throw new Error('Tool not found in mock'); - }); - - const moduleRef = await Test.createTestingModule({ - providers: [ - ToolAdapterService, - { - provide: ToolRegistryService, - useValue: mockRegistry, - }, - { - provide: PermissionsService, - useValue: mockPermissions, - }, - ], - }).compile(); - - service = moduleRef.get(ToolAdapterService); - }); - - it('should include unflagged tools regardless of role/workspace', async () => { - const toolsNoContext = await service.getTools(); - - expect(Object.keys(toolsNoContext)).toContain('http_request'); - - const toolsWithPartialContext = await service.getTools({ - unionOf: ['role-1'], - }); - - expect(Object.keys(toolsWithPartialContext)).toContain('http_request'); - }); - - it('should not include flagged tools when role/workspace are missing', async () => { - const toolsNoContext = await service.getTools(); - - expect(Object.keys(toolsNoContext)).not.toContain('send_email'); - - const toolsRoleOnly = await service.getTools({ - unionOf: ['role-1'], - }); - - expect(Object.keys(toolsRoleOnly)).not.toContain('send_email'); - - const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1'); - - expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email'); - }); - - it('should include flagged tools when permission is granted', async () => { - mockPermissions.hasToolPermission.mockResolvedValueOnce(true); - - const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1'); - - expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith( - { unionOf: ['role-1'] }, - 'ws-1', - PermissionFlagType.SEND_EMAIL_TOOL, - ); - - expect(Object.keys(tools)).toContain('send_email'); - }); - - it('should exclude flagged tools when permission is denied', async () => { - mockPermissions.hasToolPermission.mockResolvedValueOnce(false); - - const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1'); - - expect(Object.keys(tools)).not.toContain('send_email'); - }); - - it('should lowercase tool type keys in the returned ToolSet', async () => { - const tools = await service.getTools(); - - const keys = Object.keys(tools); - - expect(keys).toContain('http_request'); - expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is - }); - - it('should forward execute input correctly and return underlying result', async () => { - const tools = await service.getTools(); - - const input = { url: 'https://example.com', method: 'GET' } as ToolInput; - const result = await tools['http_request'].execute?.( - { input }, - { - toolCallId: 'test-tool-call-id', - messages: [ - { - role: 'user', - content: 'content', - }, - ], - }, - ); - - // Ensure wrapper forwards only parameters.input - expect(unflaggedToolExecute).toHaveBeenCalledWith(input); - expect(result).toEqual({ - success: true, - message: 'Tool executed successfully', - result: { echoed: input }, - }); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool.service.spec.ts deleted file mode 100644 index c4a0a34ace..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/__tests__/tool.service.spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Test } from '@nestjs/testing'; - -import { FieldActorSource } from 'twenty-shared/types'; - -import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; -import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; -import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; -import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; -import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service'; -import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; - -describe('ToolService', () => { - const workspaceId = 'ws_1'; - const roleId = 'role_1'; - - let service: ToolService; - let perObjectToolGenerator: PerObjectToolGeneratorService; - - const mockTools = { - create_testObject: { - description: 'Create a test object', - inputSchema: {}, - execute: jest.fn(), - }, - update_testObject: { - description: 'Update a test object', - inputSchema: {}, - execute: jest.fn(), - }, - find_testObject: { - description: 'Find test objects', - inputSchema: {}, - execute: jest.fn(), - }, - soft_delete_testObject: { - description: 'Soft delete a test object', - inputSchema: {}, - execute: jest.fn(), - }, - }; - - beforeEach(async () => { - jest.resetAllMocks(); - - const moduleRef = await Test.createTestingModule({ - providers: [ - ToolService, - { - provide: PerObjectToolGeneratorService, - useValue: { - generate: jest.fn().mockResolvedValue(mockTools), - }, - }, - { - provide: CreateRecordService, - useValue: { execute: jest.fn() }, - }, - { - provide: UpdateRecordService, - useValue: { execute: jest.fn() }, - }, - { - provide: DeleteRecordService, - useValue: { execute: jest.fn() }, - }, - { - provide: FindRecordsService, - useValue: { execute: jest.fn() }, - }, - ], - }).compile(); - - service = moduleRef.get(ToolService); - perObjectToolGenerator = moduleRef.get(PerObjectToolGeneratorService); - }); - - describe('listTools', () => { - it('should call perObjectToolGenerator.generate with correct parameters', async () => { - const tools = await service.listTools({ unionOf: [roleId] }, workspaceId); - - expect(perObjectToolGenerator.generate).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId, - rolePermissionConfig: { unionOf: [roleId] }, - }), - expect.any(Array), - undefined, - ); - - expect(tools).toBe(mockTools); - }); - - it('should pass toolHints to perObjectToolGenerator.generate', async () => { - const toolHints: ToolHints = { - relevantObjects: ['company', 'person'], - operations: ['create', 'find'], - }; - - await service.listTools( - { unionOf: [roleId] }, - workspaceId, - undefined, - toolHints, - ); - - expect(perObjectToolGenerator.generate).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId, - rolePermissionConfig: { unionOf: [roleId] }, - }), - expect.any(Array), - toolHints, - ); - }); - - it('should pass actorContext to perObjectToolGenerator.generate', async () => { - const actorContext = { - source: FieldActorSource.API, - workspaceMemberId: 'member_1', - name: 'Test User', - context: {}, - }; - - await service.listTools({ unionOf: [roleId] }, workspaceId, actorContext); - - expect(perObjectToolGenerator.generate).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId, - rolePermissionConfig: { unionOf: [roleId] }, - actorContext, - }), - expect.any(Array), - undefined, - ); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service.ts deleted file mode 100644 index 5e2797e55f..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type ToolSet } from 'ai'; - -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; -import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; -import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; -import { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; - -@Injectable() -export class ToolAdapterService { - constructor( - private readonly toolRegistry: ToolRegistryService, - private readonly permissionsService: PermissionsService, - ) {} - - async getTools( - rolePermissionConfig?: RolePermissionConfig, - workspaceId?: string, - ): Promise { - const tools: ToolSet = {}; - - for (const toolType of this.toolRegistry.getAllToolTypes()) { - const tool = this.toolRegistry.getTool(toolType); - - if (!tool.flag) { - tools[toolType.toLowerCase()] = this.createToolSet(tool); - } else if (rolePermissionConfig && workspaceId) { - const hasPermission = await this.permissionsService.hasToolPermission( - rolePermissionConfig, - workspaceId, - tool.flag as PermissionFlagType, - ); - - if (hasPermission) { - tools[toolType.toLowerCase()] = this.createToolSet(tool); - } - } - } - - return tools; - } - - private createToolSet(tool: Tool) { - return { - description: tool.description, - inputSchema: tool.inputSchema, - execute: async (parameters: { input: ToolInput }) => - tool.execute(parameters.input), - }; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool.service.ts deleted file mode 100644 index cffb27fbec..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool.service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Injectable } from '@nestjs/common'; - -import { type ToolSet } from 'ai'; -import { type ActorMetadata } from 'twenty-shared/types'; - -import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; -import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; -import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; -import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; -import { - createDirectRecordToolsFactory, - type DirectRecordToolsDeps, -} from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory'; -import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service'; -import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; - -@Injectable() -export class ToolService { - private readonly directRecordToolsDeps: DirectRecordToolsDeps; - - constructor( - private readonly perObjectToolGenerator: PerObjectToolGeneratorService, - createRecordService: CreateRecordService, - updateRecordService: UpdateRecordService, - deleteRecordService: DeleteRecordService, - findRecordsService: FindRecordsService, - ) { - this.directRecordToolsDeps = { - createRecordService, - updateRecordService, - deleteRecordService, - findRecordsService, - }; - } - - // Generates AI tools for database operations based on workspace objects and permissions - async listTools( - rolePermissionConfig: RolePermissionConfig, - workspaceId: string, - actorContext?: ActorMetadata, - toolHints?: ToolHints, - ): Promise { - const directRecordToolsFactory = createDirectRecordToolsFactory( - this.directRecordToolsDeps, - ); - - return this.perObjectToolGenerator.generate( - { - workspaceId, - rolePermissionConfig, - actorContext, - }, - [directRecordToolsFactory], - toolHints, - ); - } -} diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index 8890f8b268..efd9c010ad 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -52,7 +52,6 @@ export const SEARCH_FIELDS_FOR_COMPANY: FieldTypeAndNameMetadata[] = [ @WorkspaceEntity({ standardId: STANDARD_OBJECT_IDS.company, - namePlural: 'companies', labelSingular: msg`Company`, labelPlural: msg`Companies`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts index a0ea015a83..34df3efb74 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/tool-executor-workflow-action.ts @@ -4,16 +4,27 @@ import { resolveInput } from 'twenty-shared/utils'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; -import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @Injectable() export class ToolExecutorWorkflowAction implements WorkflowAction { - constructor(private readonly toolRegistry: ToolRegistryService) {} + private readonly toolsByActionType: Map; + + constructor( + private readonly httpTool: HttpTool, + private readonly sendEmailTool: SendEmailTool, + ) { + this.toolsByActionType = new Map([ + [WorkflowActionType.HTTP_REQUEST, this.httpTool], + [WorkflowActionType.SEND_EMAIL, this.sendEmailTool], + ]); + } async execute({ currentStepId, @@ -26,20 +37,10 @@ export class ToolExecutorWorkflowAction implements WorkflowAction { throw new Error('Step not found'); } - const toolType = this.mapWorkflowActionTypeToToolType(step.type); - - if (!toolType) { - throw new Error( - `No tool mapping found for workflow action type: ${step.type}`, - ); - } - - const tool = this.toolRegistry.getTool(toolType); + const tool = this.toolsByActionType.get(step.type); if (!tool) { - throw new Error( - `Tool for action type ${step.type} not found in registry`, - ); + throw new Error(`No tool found for workflow action type: ${step.type}`); } const toolInput = resolveInput(step.settings.input, context) as ToolInput; @@ -51,15 +52,4 @@ export class ToolExecutorWorkflowAction implements WorkflowAction { error: toolOutput.error, }; } - - private mapWorkflowActionTypeToToolType( - actionType: WorkflowActionType, - ): ToolType | null { - const mapping: Partial> = { - [WorkflowActionType.HTTP_REQUEST]: ToolType.HTTP_REQUEST, - [WorkflowActionType.SEND_EMAIL]: ToolType.SEND_EMAIL, - }; - - return mapping[actionType] || null; - } } diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts index 1ab2588b8e..981c7ed8fc 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts @@ -1,7 +1,5 @@ import { type ToolSet } from 'ai'; -import { fromObjectMetadataEntityToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util'; - import { type AgentToolTestContext, createAgentToolTestModule, @@ -68,49 +66,15 @@ describe('AgentToolGeneratorService Integration', () => { describe('Tool Generation', () => { it('should generate complete tool set for agent with full permissions', async () => { - // Arrange - const roleWithFullPermissions = { - ...context.testRole, - canDestroyAllObjectRecords: true, - }; - - jest - .spyOn(context.roleRepository, 'find') - .mockResolvedValue([roleWithFullPermissions]); - jest - .spyOn(context.workspaceCacheService, 'getOrRecompute') - .mockResolvedValue({ - rolesPermissions: { - [context.testRoleId]: { - [context.testObjectMetadata.id]: { - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: true, - canDestroyObjectRecords: true, - restrictedFields: {}, - }, - }, - }, - } as any); - jest - .spyOn(context.objectMetadataService, 'findManyWithinWorkspace') - .mockResolvedValue([ - fromObjectMetadataEntityToFlatObjectMetadata( - context.testObjectMetadata, - ), - ]); - - // Configure perObjectToolGeneratorService to return the expected tools - jest - .spyOn(context.perObjectToolGeneratorService, 'generate') - .mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: true, - canUpdate: true, - canDelete: true, - }), - ); + // Configure toolProviderService to return the expected tools + jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( + createMockTools('testObject', { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }), + ); // Act const tools = await context.agentToolService.generateToolsForAgent( @@ -132,44 +96,15 @@ describe('AgentToolGeneratorService Integration', () => { }); it('should generate read-only tools for agent with read permissions only', async () => { - // Arrange - jest - .spyOn(context.roleRepository, 'find') - .mockResolvedValue([context.testRole]); - jest - .spyOn(context.workspaceCacheService, 'getOrRecompute') - .mockResolvedValue({ - rolesPermissions: { - [context.testRoleId]: { - [context.testObjectMetadata.id]: { - canReadObjectRecords: true, - canUpdateObjectRecords: false, - canSoftDeleteObjectRecords: false, - canDestroyObjectRecords: false, - restrictedFields: {}, - }, - }, - }, - } as any); - jest - .spyOn(context.objectMetadataService, 'findManyWithinWorkspace') - .mockResolvedValue([ - fromObjectMetadataEntityToFlatObjectMetadata( - context.testObjectMetadata, - ), - ]); - - // Configure perObjectToolGeneratorService to return read-only tools - jest - .spyOn(context.perObjectToolGeneratorService, 'generate') - .mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: false, - canUpdate: false, - canDelete: false, - }), - ); + // Configure toolProviderService to return read-only tools + jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( + createMockTools('testObject', { + canRead: true, + canCreate: false, + canUpdate: false, + canDelete: false, + }), + ); // Act const tools = await context.agentToolService.generateToolsForAgent( @@ -189,6 +124,9 @@ describe('AgentToolGeneratorService Integration', () => { }); it('should return no tool for agent without role', async () => { + // Configure toolProviderService to return empty tools when no role + jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue({}); + // Act const tools = await context.agentToolService.generateToolsForAgent( context.testAgentId, @@ -200,49 +138,16 @@ describe('AgentToolGeneratorService Integration', () => { }); it('should filter out workflow-run objects', async () => { - // Arrange - const workflowObject = { - ...context.testObjectMetadata, - nameSingular: 'workflow', - namePlural: 'workflows', - }; - - jest - .spyOn(context.roleRepository, 'find') - .mockResolvedValue([context.testRole]); - jest - .spyOn(context.workspaceCacheService, 'getOrRecompute') - .mockResolvedValue({ - rolesPermissions: { - [context.testRoleId]: { - [workflowObject.id]: { - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: true, - canDestroyObjectRecords: false, - restrictedFields: {}, - }, - }, - }, - } as any); - jest - .spyOn(context.objectMetadataService, 'findManyWithinWorkspace') - .mockResolvedValue([ - fromObjectMetadataEntityToFlatObjectMetadata(workflowObject), - ]); - - // Note: workflow objects are filtered out by PerObjectToolGeneratorService, + // Note: workflow objects are filtered out by ToolProviderService, // so the mock returns tools for testObject (non-workflow) to simulate this behavior - jest - .spyOn(context.perObjectToolGeneratorService, 'generate') - .mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: true, - canUpdate: true, - canDelete: true, - }), - ); + jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( + createMockTools('testObject', { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }), + ); // Act const tools = await context.agentToolService.generateToolsForAgent( diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index d76641dcc9..43843cd1f2 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -4,31 +4,16 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { type Repository } from 'typeorm'; import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; -import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; -import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; -import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; -import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service'; -import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; -import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; -import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; +import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; import { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service'; import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service'; -import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service'; -import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; export interface AgentToolTestContext { @@ -38,7 +23,7 @@ export interface AgentToolTestContext { objectMetadataService: ObjectMetadataService; roleRepository: Repository; workspaceCacheService: WorkspaceCacheService; - perObjectToolGeneratorService: PerObjectToolGeneratorService; + toolProviderService: ToolProviderService; twentyORMGlobalManager: TwentyORMGlobalManager; testAgent: AgentEntity & { roleId: string | null }; testRole: RoleEntity; @@ -97,89 +82,11 @@ export const createAgentToolTestModule = }, }, { - provide: PerObjectToolGeneratorService, + provide: ToolProviderService, useValue: { - generate: jest.fn().mockResolvedValue({}), + getTools: jest.fn().mockResolvedValue({}), }, }, - { - provide: ToolService, - useClass: ToolService, - }, - { - provide: CreateRecordService, - useValue: { - execute: jest.fn(), - }, - }, - { - provide: UpdateRecordService, - useValue: { - execute: jest.fn(), - }, - }, - { - provide: DeleteRecordService, - useValue: { - execute: jest.fn(), - }, - }, - { - provide: FindRecordsService, - useValue: { - execute: jest.fn().mockResolvedValue({ - success: true, - message: 'Records found successfully', - result: [], - }), - }, - }, - { - provide: RecordInputTransformerService, - useValue: { - process: jest.fn(async ({ recordInput }) => recordInput), - }, - }, - { - provide: WorkspaceManyOrAllFlatEntityMapsCacheService, - useValue: { - getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(), - }, - }, - { - provide: ToolAdapterService, - useClass: ToolAdapterService, - }, - { - provide: ToolRegistryService, - useClass: ToolRegistryService, - }, - { - provide: SendEmailTool, - useValue: { - description: 'mock', - inputSchema: {}, - execute: jest.fn(), - }, - }, - { - provide: SearchArticlesTool, - useValue: { - description: 'Search for articles and documentation', - inputSchema: {}, - execute: jest.fn(), - }, - }, - { - provide: ScopedWorkspaceContextFactory, - useValue: { - create: jest.fn(() => ({ workspaceId: 'test-workspace-id' })), - }, - }, - { - provide: MessagingSendMessageService, - useValue: { sendMessage: jest.fn() }, - }, { provide: PermissionsService, useValue: { @@ -188,18 +95,6 @@ export const createAgentToolTestModule = checkRolesPermissions: jest.fn().mockResolvedValue(true), }, }, - { - provide: WorkflowToolWorkspaceService, - useValue: { - generateWorkflowTools: jest.fn().mockResolvedValue({}), - }, - }, - { - provide: TwentyConfigService, - useValue: { - get: jest.fn(), - }, - }, ], }).compile(); @@ -286,44 +181,12 @@ export const createAgentToolTestModule = fieldPermissions: [], }); - // Ensure ToolService input transformation has access to minimal metadata maps - const workspaceManyOrAllFlatEntityMapsCacheService = - module.get( - WorkspaceManyOrAllFlatEntityMapsCacheService, - ); - - // Return a barebones flat object metadata map where fields are unknown (so transformer is a no-op) - const getMapsMock = - workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps as jest.Mock; - - getMapsMock.mockResolvedValue({ - flatObjectMetadataMaps: { - byId: { - [testObjectMetadata.id]: { - ...testObjectMetadata, - fieldMetadataIds: [], - indexMetadataIds: [], - viewIds: [], - universalIdentifier: testObjectMetadata.id, - applicationId: null, - } as any, - }, - idByUniversalIdentifier: {}, - universalIdentifiersByApplicationId: {}, - }, - flatFieldMetadataMaps: { - byId: {}, - idByUniversalIdentifier: {}, - universalIdentifiersByApplicationId: {}, - }, - } as any); - const workspaceCacheService = module.get( WorkspaceCacheService, ); - const perObjectToolGeneratorService = - module.get(PerObjectToolGeneratorService); + const toolProviderService = + module.get(ToolProviderService); return { module, @@ -332,7 +195,7 @@ export const createAgentToolTestModule = objectMetadataService, roleRepository, workspaceCacheService, - perObjectToolGeneratorService, + toolProviderService, twentyORMGlobalManager, testAgent, testRole,