From 9cecbaebc330dd41a2a6292bc883aa9ab7e87adb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 4 Dec 2025 21:06:49 +0100 Subject: [PATCH] refactor(workflow-tools): reorganize to one file per tool with co-located schemas (#16313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reorganizes workflow tools to improve maintainability and discoverability by having one file per tool with co-located input schemas. ## Changes - Create individual tool files in `tools/` directory (11 files) - Co-locate input schemas with their tool implementations - Add shared types file for dependencies and context - Simplify workspace service to aggregate tool factories - Remove centralized `schemas/` directory ## New Structure ``` workflow-tools/ ├── services/ │ └── workflow-tool.workspace-service.ts ├── tools/ │ ├── activate-workflow-version.tool.ts │ ├── compute-step-output-schema.tool.ts │ ├── create-complete-workflow.tool.ts │ ├── create-draft-from-workflow-version.tool.ts │ ├── create-workflow-version-edge.tool.ts │ ├── create-workflow-version-step.tool.ts │ ├── deactivate-workflow-version.tool.ts │ ├── delete-workflow-version-edge.tool.ts │ ├── delete-workflow-version-step.tool.ts │ ├── update-workflow-version-positions.tool.ts │ └── update-workflow-version-step.tool.ts ├── types/ │ └── workflow-tool-dependencies.type.ts └── workflow-tools.module.ts ``` ## Benefits - **Co-location**: Schema and tool logic are in the same file - **Single responsibility**: Each file handles one tool - **Easier maintenance**: Changes to a tool only touch one file - **Better discoverability**: File names match tool names --- .../direct-record-tools.factory.ts | 140 +++++ .../per-object-tool-generator.service.ts | 178 ++++++ .../tool-generator/tool-generator.module.ts | 13 + .../types/tool-generator.types.ts | 31 + .../ai-agent-execution.module.ts | 33 +- .../services/agent-actor-context.service.ts | 0 .../services/agent-async-executor.service.ts | 125 +++- .../services/agent-execution.service.ts | 33 +- .../services/agent-model-config.service.ts | 0 .../services/agent-plan-executor.service.ts | 15 +- .../services/agent-tool-generator.service.ts | 22 +- .../types/agent-execution-result.type.ts | 6 + .../ai/ai-agent/ai-agent.module.ts | 45 +- .../ai/ai-chat/ai-chat.module.ts | 8 + .../services/agent-chat-routing.service.ts | 27 +- .../ai/ai-chat/services/agent-chat.service.ts | 3 +- .../agent-title-generation.service.ts | 0 .../services/chat-tools-provider.service.ts | 64 ++ .../ai/ai-tools/ai-tools.module.ts | 2 + .../services/__tests__/tool.service.spec.ts | 193 +++--- .../ai/ai-tools/services/tool.service.ts | 343 +--------- .../types/workflow-action-type.enum.ts | 16 + .../types/workflow-action.type.ts | 25 +- .../builders/create-record-step.builder.ts | 94 +++ .../builders/delete-record-step.builder.ts | 87 +++ .../builders/find-records-step.builder.ts | 149 +++++ .../factories/builders/step-builder.utils.ts | 52 ++ .../builders/update-record-step.builder.ts | 101 +++ .../factories/workflow-step-tools.factory.ts | 75 +++ .../schemas/workflow-tool-schemas.ts | 143 ----- .../workflow-tool.workspace-service.ts | 593 ++++-------------- .../tools/activate-workflow-version.tool.ts | 35 ++ .../tools/compute-step-output-schema.tool.ts | 47 ++ .../tools/create-complete-workflow.tool.ts | 294 +++++++++ ...create-draft-from-workflow-version.tool.ts | 42 ++ .../create-workflow-version-edge.tool.ts | 53 ++ .../create-workflow-version-step.tool.ts | 64 ++ .../tools/deactivate-workflow-version.tool.ts | 35 ++ .../delete-workflow-version-edge.tool.ts | 52 ++ .../delete-workflow-version-step.tool.ts | 42 ++ .../update-workflow-version-positions.tool.ts | 47 ++ .../update-workflow-version-step.tool.ts | 45 ++ .../types/workflow-tool-dependencies.type.ts | 21 + .../workflow-tools/workflow-tools.module.ts | 2 + .../agent-tool.service.integration-spec.ts | 98 +++ .../agent/utils/agent-tool-test-utils.ts | 16 +- 46 files changed, 2323 insertions(+), 1186 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-generator/tool-generator.module.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-generator/types/tool-generator.types.ts rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-agent-execution}/services/agent-actor-context.service.ts (100%) rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-agent-execution}/services/agent-execution.service.ts (93%) rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-agent-execution}/services/agent-model-config.service.ts (100%) rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-agent-execution}/services/agent-plan-executor.service.ts (94%) rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-agent-execution}/services/agent-tool-generator.service.ts (84%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts rename packages/twenty-server/src/engine/metadata-modules/ai/{ai-agent => ai-chat}/services/agent-title-generation.service.ts (100%) create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-draft-from-workflow-version.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/deactivate-workflow-version.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts create mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts b/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts new file mode 100644 index 0000000000..71860b0657 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts @@ -0,0 +1,140 @@ +import { type ToolSet } from 'ai'; + +import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; +import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; +import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; +import { type UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util'; +import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util'; +import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema'; +import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema'; +import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema'; +import { + type ObjectWithPermission, + type ToolGeneratorContext, +} from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; + +// Dependencies required by the direct record tools factory +export type DirectRecordToolsDeps = { + createRecordService: CreateRecordService; + updateRecordService: UpdateRecordService; + deleteRecordService: DeleteRecordService; + findRecordsService: FindRecordsService; +}; + +export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => { + return ( + { + objectMetadata, + restrictedFields, + canCreate, + canRead, + canUpdate, + canDelete, + }: ObjectWithPermission, + context: ToolGeneratorContext, + ): ToolSet => { + const tools: ToolSet = {}; + + if (canRead) { + tools[`find_${objectMetadata.nameSingular}`] = { + description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`, + inputSchema: generateFindToolInputSchema( + objectMetadata, + restrictedFields, + ), + execute: async (parameters) => { + const { limit, offset, orderBy, ...filter } = parameters.input; + + return deps.findRecordsService.execute({ + objectName: objectMetadata.nameSingular, + filter, + orderBy, + limit, + offset, + workspaceId: context.workspaceId, + rolePermissionConfig: context.rolePermissionConfig, + }); + }, + }; + + tools[`find_one_${objectMetadata.nameSingular}`] = { + description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, + inputSchema: FindOneToolInputSchema, + execute: async (parameters) => { + return deps.findRecordsService.execute({ + objectName: objectMetadata.nameSingular, + filter: { id: { eq: parameters.input.id } }, + limit: 1, + workspaceId: context.workspaceId, + rolePermissionConfig: context.rolePermissionConfig, + }); + }, + }; + } + + if (canCreate) { + tools[`create_${objectMetadata.nameSingular}`] = { + description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, + inputSchema: generateCreateRecordInputSchema( + objectMetadata, + restrictedFields, + ), + execute: async (parameters) => { + return deps.createRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecord: parameters.input, + workspaceId: context.workspaceId, + rolePermissionConfig: context.rolePermissionConfig, + createdBy: context.actorContext, + }); + }, + }; + } + + if (canUpdate) { + tools[`update_${objectMetadata.nameSingular}`] = { + description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, + inputSchema: generateUpdateRecordInputSchema( + objectMetadata, + restrictedFields, + ), + execute: async (parameters) => { + const { id, ...allFields } = parameters.input; + + const objectRecord = Object.fromEntries( + Object.entries(allFields).filter( + ([, value]) => value !== undefined, + ), + ); + + return deps.updateRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecordId: id, + objectRecord, + workspaceId: context.workspaceId, + rolePermissionConfig: context.rolePermissionConfig, + }); + }, + }; + } + + if (canDelete) { + tools[`soft_delete_${objectMetadata.nameSingular}`] = { + description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`, + inputSchema: SoftDeleteToolInputSchema, + execute: async (parameters) => { + return deps.deleteRecordService.execute({ + objectName: objectMetadata.nameSingular, + objectRecordId: parameters.input.id, + workspaceId: context.workspaceId, + rolePermissionConfig: context.rolePermissionConfig, + soft: true, + }); + }, + }; + } + + return tools; + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts b/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts new file mode 100644 index 0000000000..87d6a7603b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts @@ -0,0 +1,178 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { isDefined } from 'twenty-shared/utils'; + +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { + type ObjectWithPermission, + type ToolFactory, + type ToolGeneratorContext, +} from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; +import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; +import { + type ToolHints, + type ToolOperation, +} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +@Injectable() +export class PerObjectToolGeneratorService { + private readonly logger = new Logger(PerObjectToolGeneratorService.name); + + constructor( + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + // Generate tools by iterating over workspace objects once and applying all factories + async generate( + context: ToolGeneratorContext, + factories: ToolFactory[], + toolHints?: ToolHints, + ): Promise { + const objects = await this.getFilteredObjectsWithPermissions( + context.workspaceId, + context.rolePermissionConfig, + toolHints, + ); + + const tools: ToolSet = {}; + + for (const objectWithPermission of objects) { + for (const factory of factories) { + Object.assign(tools, factory(objectWithPermission, context)); + } + } + + this.logger.log( + `Generated ${Object.keys(tools).length} tools from ${factories.length} factories for ${objects.length} objects`, + ); + + return tools; + } + + // Get workspace objects with their permissions, filtered by toolHints + async getFilteredObjectsWithPermissions( + workspaceId: string, + rolePermissionConfig: RolePermissionConfig, + toolHints?: ToolHints, + ): Promise { + const { rolesPermissions } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'rolesPermissions', + ]); + + let objectPermissions; + + if ('unionOf' in rolePermissionConfig) { + if (rolePermissionConfig.unionOf.length === 1) { + objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]]; + } else { + throw new Error( + 'Union permission logic for multiple roles not yet implemented', + ); + } + } else if ('intersectionOf' in rolePermissionConfig) { + const allRolePermissions = rolePermissionConfig.intersectionOf.map( + (roleId: string) => rolesPermissions[roleId], + ); + + objectPermissions = + allRolePermissions.length === 1 + ? allRolePermissions[0] + : computePermissionIntersection(allRolePermissions); + } else { + return []; + } + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const allFlatObjects = Object.values(flatObjectMetadataMaps.byId) + .filter(isDefined) + .filter((obj) => obj.isActive && !obj.isSystem); + + const allObjectMetadata = allFlatObjects.map((flatObject) => ({ + ...flatObject, + fields: getFlatFieldsFromFlatObjectMetadata( + flatObject, + flatFieldMetadataMaps, + ), + })); + + // Filter out workflow-related objects + let filteredObjectMetadata = allObjectMetadata.filter( + (objectMetadata) => !isWorkflowRelatedObject(objectMetadata), + ); + + // Apply toolHints filtering if provided + if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) { + const relevantSet = new Set(toolHints.relevantObjects); + const originalCount = filteredObjectMetadata.length; + + filteredObjectMetadata = filteredObjectMetadata.filter( + (obj) => + relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural), + ); + + this.logger.log( + `Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`, + ); + + if (filteredObjectMetadata.length === 0) { + this.logger.warn( + `Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`, + ); + } + } + + // Map to ObjectWithPermission + const result: ObjectWithPermission[] = []; + + const operationsSet = toolHints?.operations + ? new Set(toolHints.operations) + : null; + + const shouldIncludeOperation = (operation: ToolOperation) => + !operationsSet || operationsSet.has(operation); + + for (const objectMetadata of filteredObjectMetadata) { + const permission = objectPermissions[objectMetadata.id]; + + if (!permission) { + continue; + } + + result.push({ + objectMetadata, + restrictedFields: permission.restrictedFields, + canCreate: + shouldIncludeOperation('create') && permission.canUpdateObjectRecords, + canRead: + shouldIncludeOperation('find') && permission.canReadObjectRecords, + canUpdate: + shouldIncludeOperation('update') && permission.canUpdateObjectRecords, + canDelete: + shouldIncludeOperation('delete') && + permission.canSoftDeleteObjectRecords, + }); + } + + if (operationsSet) { + this.logger.log( + `Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`, + ); + } + + return result; + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-generator/tool-generator.module.ts b/packages/twenty-server/src/engine/core-modules/tool-generator/tool-generator.module.ts new file mode 100644 index 0000000000..5a96bddd96 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-generator/tool-generator.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; + +import { PerObjectToolGeneratorService } from './services/per-object-tool-generator.service'; + +@Module({ + imports: [WorkspaceCacheModule, WorkspaceManyOrAllFlatEntityMapsCacheModule], + providers: [PerObjectToolGeneratorService], + exports: [PerObjectToolGeneratorService], +}) +export class ToolGeneratorModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool-generator/types/tool-generator.types.ts b/packages/twenty-server/src/engine/core-modules/tool-generator/types/tool-generator.types.ts new file mode 100644 index 0000000000..57ae5f52ba --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-generator/types/tool-generator.types.ts @@ -0,0 +1,31 @@ +import { type ToolSet } from 'ai'; +import { + type ActorMetadata, + type RestrictedFieldsPermissions, +} from 'twenty-shared/types'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +// Object metadata enriched with permission information +export type ObjectWithPermission = { + objectMetadata: ObjectMetadataForToolSchema; + restrictedFields: RestrictedFieldsPermissions; + canCreate: boolean; + canRead: boolean; + canUpdate: boolean; + canDelete: boolean; +}; + +// Context passed to tool factories +export type ToolGeneratorContext = { + workspaceId: string; + rolePermissionConfig: RolePermissionConfig; + actorContext?: ActorMetadata; +}; + +// Factory function type for generating tools from an object +export type ToolFactory = ( + objectWithPermission: ObjectWithPermission, + context: ToolGeneratorContext, +) => ToolSet; 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 99994479be..304b5a22a8 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,31 +1,62 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.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'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { AgentMessagePartEntity } from './entities/agent-message-part.entity'; import { AgentMessageEntity } from './entities/agent-message.entity'; 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'; @Module({ imports: [ AiBillingModule, AiModelsModule, AiToolsModule, + AiAgentModule, + WorkspaceDomainsModule, + UserWorkspaceModule, + UserRoleModule, + PermissionsModule, + WorkspaceCacheModule, TypeOrmModule.forFeature([ + AgentEntity, AgentMessageEntity, AgentMessagePartEntity, AgentTurnEntity, RoleTargetEntity, ]), ], - providers: [AgentAsyncExecutorService], + providers: [ + AgentAsyncExecutorService, + AgentExecutionService, + AgentToolGeneratorService, + AgentModelConfigService, + AgentActorContextService, + AgentPlanExecutorService, + ], exports: [ AgentAsyncExecutorService, + AgentExecutionService, + AgentPlanExecutorService, + AgentToolGeneratorService, + AgentActorContextService, + AgentModelConfigService, TypeOrmModule.forFeature([ AgentMessageEntity, AgentMessagePartEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-actor-context.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts 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 51824a08f8..9ebc0f2e3e 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,7 @@ import { import { type ActorMetadata } from 'twenty-shared/types'; import { Repository } from 'typeorm'; +import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; import { AgentException, AgentExceptionCode, @@ -18,7 +19,7 @@ import { import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service'; +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'; @@ -26,18 +27,43 @@ import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/to 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'; + +// Agent execution within workflows uses database and action tools only. +// Workflow tools are intentionally excluded to avoid circular dependencies +// and recursive workflow execution. @Injectable() export class AgentAsyncExecutorService { private readonly logger = new Logger(AgentAsyncExecutorService.name); + constructor( private readonly aiModelRegistryService: AiModelRegistryService, + private readonly agentModelConfigService: AgentModelConfigService, private readonly toolAdapterService: ToolAdapterService, + private readonly toolService: ToolService, @InjectRepository(RoleTargetEntity) private readonly roleTargetRepository: Repository, - private readonly toolService: ToolService, ) {} - private async getTools( + private extractRoleIds( + rolePermissionConfig?: RolePermissionConfig, + ): string[] { + if (!rolePermissionConfig) { + return []; + } + + if ('intersectionOf' in rolePermissionConfig) { + return rolePermissionConfig.intersectionOf; + } + + if ('unionOf' in rolePermissionConfig) { + return rolePermissionConfig.unionOf; + } + + return []; + } + + private async getToolsForWorkflowExecution( agentId: string, workspaceId: string, actorContext?: ActorMetadata, @@ -45,43 +71,42 @@ export class AgentAsyncExecutorService { ): Promise { const roleTarget = await this.roleTargetRepository.findOne({ where: { - agentId: agentId, + agentId, workspaceId, }, select: ['roleId'], }); const agentRoleId = roleTarget?.roleId; + const configRoleIds = this.extractRoleIds(rolePermissionConfig); - if (!rolePermissionConfig && !agentRoleId) { - return await this.toolAdapterService.getTools(); + // 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(); } - let effectiveRoleContext: RolePermissionConfig; - - if ( - rolePermissionConfig && - ('intersectionOf' in rolePermissionConfig || - 'unionOf' in rolePermissionConfig) - ) { - effectiveRoleContext = rolePermissionConfig; - } else if (agentRoleId) { - effectiveRoleContext = { unionOf: [agentRoleId] }; - } else { - return await this.toolAdapterService.getTools(); - } - - const actionTools = await this.toolAdapterService.getTools( - effectiveRoleContext, - workspaceId, - ); + 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, @@ -103,14 +128,35 @@ export class AgentAsyncExecutorService { const registeredModel = await this.aiModelRegistryService.resolveModelForAgent(agent); - const tools = agent - ? await this.getTools( - agent.id, - agent.workspaceId, - actorContext, - rolePermissionConfig, - ) - : {}; + let tools: ToolSet = {}; + let providerOptions = {}; + + if (agent) { + tools = await this.getToolsForWorkflowExecution( + 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 }; + + providerOptions = this.agentModelConfigService.getProviderOptions( + registeredModel, + agent as unknown as Parameters< + typeof this.agentModelConfigService.getProviderOptions + >[1], + ); + } this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); @@ -120,7 +166,22 @@ export class AgentAsyncExecutorService { model: registeredModel.model, prompt: userPrompt, stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS), + providerOptions, experimental_telemetry: AI_TELEMETRY_CONFIG, + experimental_repairToolCall: async ({ + toolCall, + tools: toolsForRepair, + inputSchema, + error, + }) => { + return repairToolCall({ + toolCall, + tools: toolsForRepair, + inputSchema, + error, + model: registeredModel.model, + }); + }, }); const agentSchema = diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts similarity index 93% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts index 1c7379fb2a..4f7ced28b0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts @@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { convertToModelMessages, - LanguageModelUsage, stepCountIs, streamText, ToolSet, @@ -24,9 +23,6 @@ import { import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; -import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-actor-context.service'; -import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-model-config.service'; -import { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.service'; import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; @@ -37,10 +33,12 @@ import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/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'; -export interface AgentExecutionResult { - result: object; - usage: LanguageModelUsage; -} +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 +export { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; export interface StreamChatResponseResult { stream: ReturnType; @@ -63,8 +61,8 @@ export class AgentExecutionService { private readonly agentToolGeneratorService: AgentToolGeneratorService, private readonly agentModelConfigService: AgentModelConfigService, private readonly aiBillingService: AIBillingService, - public readonly agentActorContextService: AgentActorContextService, - public readonly agentService: AgentService, + private readonly agentActorContextService: AgentActorContextService, + private readonly agentService: AgentService, private readonly workspaceCacheService: WorkspaceCacheService, ) {} @@ -75,6 +73,7 @@ export class AgentExecutionService { actorContext, roleIds, toolHints, + additionalTools, }: { system: string; agent: FlatAgentWithRoleId | null; @@ -82,6 +81,7 @@ export class AgentExecutionService { actorContext?: ActorMetadata; roleIds?: string[]; toolHints?: ToolHints; + additionalTools?: ToolSet; }) { try { if (agent) { @@ -112,7 +112,11 @@ export class AgentExecutionService { agent, ); - tools = { ...baseTools, ...nativeModelTools }; + tools = { + ...baseTools, + ...nativeModelTools, + ...(additionalTools || {}), + }; providerOptions = this.agentModelConfigService.getProviderOptions( registeredModel, @@ -120,7 +124,9 @@ export class AgentExecutionService { ); } - this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); + this.logger.log( + `Generated ${Object.keys(tools).length} tools for agent (including ${Object.keys(additionalTools || {}).length} additional tools)`, + ); return { system, @@ -278,6 +284,7 @@ export class AgentExecutionService { messages, recordIdsByObjectMetadataNameSingular, toolHints, + additionalTools, }: { workspace: WorkspaceEntity; userWorkspaceId: string; @@ -285,6 +292,7 @@ export class AgentExecutionService { messages: UIMessage[]; recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; toolHints?: ToolHints; + additionalTools?: ToolSet; }): Promise<{ stream: ReturnType; timings: { @@ -345,6 +353,7 @@ export class AgentExecutionService { actorContext, roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])], toolHints, + additionalTools, }); const aiRequestPrepTime = Date.now() - aiRequestPrepStart; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-model-config.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-model-config.service.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-model-config.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-model-config.service.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-plan-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts similarity index 94% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-plan-executor.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts index 0f716bcd9d..137fd824d0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-plan-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; import { type PlanStep } from 'src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface'; import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents'; @@ -32,7 +33,10 @@ export type PlanExecutionResult = { export class AgentPlanExecutorService { private readonly logger = new Logger(AgentPlanExecutorService.name); - constructor(private readonly agentExecutionService: AgentExecutionService) {} + constructor( + private readonly agentExecutionService: AgentExecutionService, + private readonly agentService: AgentService, + ) {} async executePlan({ steps, @@ -70,11 +74,10 @@ export class AgentPlanExecutorService { `[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`, ); - const agent = - await this.agentExecutionService.agentService.findOneAgentByName({ - name: step.agentName, - workspaceId: workspace.id, - }); + const agent = await this.agentService.findOneAgentByName({ + name: step.agentName, + workspaceId: workspace.id, + }); this.logger.log( `[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts similarity index 84% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts index 075f048bba..a0da1330ad 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts @@ -11,10 +11,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag 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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent'; -import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; @Injectable() export class AgentToolGeneratorService { @@ -25,8 +22,6 @@ export class AgentToolGeneratorService { private readonly agentRepository: Repository, private readonly toolAdapterService: ToolAdapterService, private readonly toolService: ToolService, - private readonly workflowToolService: WorkflowToolService, - private readonly permissionsService: PermissionsService, private readonly searchArticlesTool: SearchArticlesTool, ) {} @@ -56,21 +51,8 @@ export class AgentToolGeneratorService { return this.wrapToolsWithErrorContext(tools); } - const hasWorkflowPermission = - await this.permissionsService.checkRolesPermissions( - { intersectionOf: roleIds }, - workspaceId, - PermissionFlagType.WORKFLOWS, - ); - - if (hasWorkflowPermission) { - const workflowTools = this.workflowToolService.generateWorkflowTools( - workspaceId, - { intersectionOf: roleIds }, - ); - - tools = { ...tools, ...workflowTools }; - } + // 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 }, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts new file mode 100644 index 0000000000..2d840c2650 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type.ts @@ -0,0 +1,6 @@ +import { type LanguageModelUsage } from 'ai'; + +export interface AgentExecutionResult { + result: object; + usage: LanguageModelUsage; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts index e54a903cc8..d1da9886e0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/ai-agent.module.ts @@ -3,48 +3,32 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { AuditModule } from 'src/engine/core-modules/audit/audit.module'; -import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; -import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; 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 { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module'; import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor'; -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 { 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 { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module'; -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; 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'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; -import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module'; -import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { AgentResolver } from './agent.resolver'; import { AgentService } from './agent.service'; import { AgentEntity } from './entities/agent.entity'; -import { AgentActorContextService } from './services/agent-actor-context.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 { AgentTitleGenerationService } from './services/agent-title-generation.service'; -import { AgentToolGeneratorService } from './services/agent-tool-generator.service'; + @Module({ imports: [ TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetEntity]), AiModelsModule, - AiToolsModule, - AiBillingModule, AiAgentRoleModule, ThrottlerModule, AuditModule, @@ -53,15 +37,7 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi FileModule, ObjectMetadataModule, PermissionsModule, - AiChatRouterModule, WorkspaceCacheStorageModule, - TokenModule, - WorkspaceDomainsModule, - WorkflowToolsModule, - UserWorkspaceModule, - UserRoleModule, - WorkspaceCacheModule, - WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceMigrationV2Module, ApplicationModule, FlatAgentModule, @@ -70,24 +46,9 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi providers: [ AgentResolver, AgentService, - AgentExecutionService, - AgentModelConfigService, - AgentPlanExecutorService, - AgentToolGeneratorService, - AgentTitleGenerationService, - AgentActorContextService, WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor, AgentGraphqlApiExceptionInterceptor, ], - exports: [ - AgentService, - AgentExecutionService, - AgentPlanExecutorService, - AgentToolGeneratorService, - AgentTitleGenerationService, - AgentActorContextService, - AgentModelConfigService, - TypeOrmModule.forFeature([AgentEntity]), - ], + exports: [AgentService, TypeOrmModule.forFeature([AgentEntity])], }) export class AiAgentModule {} 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 a3f5ae09b0..1916f4b230 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 @@ -15,6 +15,7 @@ import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-bi import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module'; import { AgentChatController } from './controllers/agent-chat.controller'; import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity'; @@ -22,6 +23,8 @@ import { AgentChatResolver } from './resolvers/agent-chat.resolver'; import { AgentChatRoutingService } from './services/agent-chat-routing.service'; import { AgentChatStreamingService } from './services/agent-chat-streaming.service'; import { AgentChatService } from './services/agent-chat.service'; +import { AgentTitleGenerationService } from './services/agent-title-generation.service'; +import { ChatToolsProviderService } from './services/chat-tools-provider.service'; @Module({ imports: [ @@ -42,6 +45,9 @@ import { AgentChatService } from './services/agent-chat.service'; TokenModule, UserWorkspaceModule, AiBillingModule, + // Provides WorkflowToolWorkspaceService for ChatToolsProviderService + // Workflow tools are only available in chat context, not in workflow executor (to avoid circular deps) + WorkflowToolsModule, ], controllers: [AgentChatController], providers: [ @@ -49,6 +55,8 @@ import { AgentChatService } from './services/agent-chat.service'; AgentChatService, AgentChatStreamingService, AgentChatRoutingService, + AgentTitleGenerationService, + ChatToolsProviderService, ], exports: [ AgentChatService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts index ea20b1b392..315ea6d86c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts @@ -6,14 +6,17 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; -import { AgentExecutionService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service'; -import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-plan-executor.service'; +import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; +import { AgentExecutionService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service'; +import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service'; import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util'; import { AiChatRouterService } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service'; import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; +import { ChatToolsProviderService } from './chat-tools-provider.service'; + export type TokenUsage = { promptTokens: number; completionTokens: number; @@ -53,6 +56,8 @@ export class AgentChatRoutingService { private readonly agentPlanExecutorService: AgentPlanExecutorService, private readonly aiChatRouterService: AiChatRouterService, private readonly aiBillingService: AIBillingService, + private readonly chatToolsProviderService: ChatToolsProviderService, + private readonly agentActorContextService: AgentActorContextService, ) {} async streamAgentExecution({ @@ -234,6 +239,23 @@ export class AgentChatRoutingService { const agentExecutionStart = Date.now(); + // Get workflow tools for chat context (these are NOT available in workflow executor) + // Use user's role for determining workflow tool permissions + const { roleId } = + await this.agentActorContextService.buildUserAndAgentActorContext( + userWorkspaceId, + workspace.id, + ); + + const roleIds = [roleId]; + + const workflowTools = + await this.chatToolsProviderService.getWorkflowToolsForChat( + workspace.id, + roleIds, + toolHints, + ); + const { stream: result, timings, @@ -245,6 +267,7 @@ export class AgentChatRoutingService { messages, recordIdsByObjectMetadataNameSingular, toolHints, + additionalTools: workflowTools, }); const routedStatusPart = { diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts index 968f6d4243..758b17b1ab 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service.ts @@ -17,9 +17,10 @@ import { AgentException, AgentExceptionCode, } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { AgentTitleGenerationService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-title-generation.service'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; +import { AgentTitleGenerationService } from './agent-title-generation.service'; + @Injectable() export class AgentChatService { constructor( diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-title-generation.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-title-generation.service.ts similarity index 100% rename from packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/services/agent-title-generation.service.ts rename to packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-title-generation.service.ts 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 new file mode 100644 index 0000000000..7965f84b21 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts @@ -0,0 +1,64 @@ +/* eslint-disable @nx/workspace-inject-workspace-repository */ +import { Injectable, Logger } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; + +import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; +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, + ) {} + + // Provides workflow-specific tools for the chat context + // These tools are NOT available in the workflow executor context to prevent circular dependencies + async getWorkflowToolsForChat( + 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, + ); + + const allWorkflowTools = { ...workflowTools, ...recordStepTools }; + + this.logger.log( + `Generated ${Object.keys(allWorkflowTools).length} workflow tools for chat context`, + ); + + return allWorkflowTools; + } +} 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 index 06da14da00..d8d3969bd8 100644 --- 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 @@ -6,6 +6,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- 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'; @@ -27,6 +28,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module'; TokenModule, FeatureFlagModule, RecordCrudModule, + ToolGeneratorModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, WorkspaceCacheStorageModule, UserRoleModule, 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 index d214666c80..c4a0a34ace 100644 --- 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 @@ -1,48 +1,44 @@ 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 { 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 { 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'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; - -// Minimal mock repository type -const createMockRepository = () => ({ - find: jest.fn(), - findOne: jest.fn(), - save: jest.fn(), - update: jest.fn(), - softDelete: jest.fn(), - delete: jest.fn(), - remove: jest.fn(), -}); describe('ToolService', () => { const workspaceId = 'ws_1'; const roleId = 'role_1'; let service: ToolService; - let workspaceCacheService: WorkspaceCacheService; + let perObjectToolGenerator: PerObjectToolGeneratorService; - const testObject = getMockObjectMetadataEntity({ - workspaceId: '', - id: 'obj_1', - nameSingular: 'testObject', - namePlural: 'testObjects', - labelSingular: 'Test Object', - labelPlural: 'Test Objects', - isActive: true, - isSystem: false, - fields: [], - }); - - const mockRepo = createMockRepository(); + 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(); @@ -51,68 +47,9 @@ describe('ToolService', () => { providers: [ ToolService, { - provide: TwentyORMGlobalManager, + provide: PerObjectToolGeneratorService, useValue: { - getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo), - }, - }, - { - provide: WorkspaceManyOrAllFlatEntityMapsCacheService, - useValue: { - getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({ - flatObjectMetadataMaps: { - byId: { - [testObject.id]: { - ...testObject, - fieldMetadataIds: [], - }, - }, - }, - flatFieldMetadataMaps: { - byId: {}, - }, - }), - }, - }, - { - provide: WorkspaceCacheService, - useValue: { - getOrRecompute: jest.fn().mockResolvedValue({ - rolesPermissions: { - [roleId]: { - [testObject.id]: { - canReadObjectRecords: true, - canUpdateObjectRecords: true, - canSoftDeleteObjectRecords: true, - canDestroyObjectRecords: false, - restrictedFields: {}, - }, - }, - }, - }), - }, - }, - { - provide: RecordInputTransformerService, - useValue: { - process: jest.fn(async ({ recordInput }) => recordInput), - }, - }, - { - provide: WorkspaceCacheStorageService, - useValue: { - getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({ - byId: { - [testObject.id]: { - ...testObject, - fieldsById: {}, - fieldIdByJoinColumnName: {}, - fieldIdByName: {}, - indexMetadatas: [], - }, - }, - idByNameSingular: { [testObject.nameSingular]: testObject.id }, - }), + generate: jest.fn().mockResolvedValue(mockTools), }, }, { @@ -135,42 +72,66 @@ describe('ToolService', () => { }).compile(); service = moduleRef.get(ToolService); - workspaceCacheService = moduleRef.get(WorkspaceCacheService); + perObjectToolGenerator = moduleRef.get(PerObjectToolGeneratorService); }); describe('listTools', () => { - it('should return tools based on role permissions', async () => { + it('should call perObjectToolGenerator.generate with correct parameters', async () => { const tools = await service.listTools({ unionOf: [roleId] }, workspaceId); - expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith( - workspaceId, - ['rolesPermissions'], + expect(perObjectToolGenerator.generate).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId, + rolePermissionConfig: { unionOf: [roleId] }, + }), + expect.any(Array), + undefined, ); - // Verify tool keys - expect(tools['create_testObject']).toBeDefined(); - expect(tools['update_testObject']).toBeDefined(); - expect(tools['find_testObject']).toBeDefined(); - expect(tools['soft_delete_testObject']).toBeDefined(); - expect(tools['soft_delete_many_testObject']).toBeDefined(); - - // Ensure the execute functions are wired - expect(typeof tools['create_testObject'].execute).toBe('function'); + expect(tools).toBe(mockTools); }); - }); - describe('softDeleteManyRecords', () => { - it('should error when filter is invalid', async () => { - const result = await (service as any).softDeleteManyRecords( - 'testObject', - {}, + it('should pass toolHints to perObjectToolGenerator.generate', async () => { + const toolHints: ToolHints = { + relevantObjects: ['company', 'person'], + operations: ['create', 'find'], + }; + + await service.listTools( + { unionOf: [roleId] }, workspaceId, - roleId, + undefined, + toolHints, ); - expect(result.success).toBe(false); - expect(result.error).toBe( - 'Filter with record IDs is required for bulk soft delete', + 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.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-tools/services/tool.service.ts index c5a9d6f7bb..cffb27fbec 100644 --- 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 @@ -1,337 +1,58 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; import { type ActorMetadata } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; 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 { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util'; -import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util'; -import { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema'; -import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema'; -import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema'; -import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema'; -import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; import { - ToolHints, - ToolOperation, -} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; -import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; + 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 logger = new Logger(ToolService.name); + private readonly directRecordToolsDeps: DirectRecordToolsDeps; constructor( - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - protected readonly workspaceCacheService: WorkspaceCacheService, - private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, - private readonly createRecordService: CreateRecordService, - private readonly updateRecordService: UpdateRecordService, - private readonly deleteRecordService: DeleteRecordService, - private readonly findRecordsService: FindRecordsService, - ) {} + 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 - // Supports filtering by object names and operation types via toolHints - // Returns a map of tool names to tool definitions async listTools( rolePermissionConfig: RolePermissionConfig, workspaceId: string, actorContext?: ActorMetadata, toolHints?: ToolHints, ): Promise { - const tools: ToolSet = {}; - - const { rolesPermissions } = - await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'rolesPermissions', - ]); - - let objectPermissions; - - if ('unionOf' in rolePermissionConfig) { - if (rolePermissionConfig.unionOf.length === 1) { - objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]]; - } else { - // TODO: Implement union logic for multiple roles - throw new Error( - 'Union permission logic for multiple roles not yet implemented', - ); - } - } else if ('intersectionOf' in rolePermissionConfig) { - const allRolePermissions = rolePermissionConfig.intersectionOf.map( - (roleId: string) => rolesPermissions[roleId], - ); - - objectPermissions = - allRolePermissions.length === 1 - ? allRolePermissions[0] - : computePermissionIntersection(allRolePermissions); - } else { - return tools; - } - - const { flatObjectMetadataMaps, flatFieldMetadataMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], - }, - ); - - const allFlatObjects = Object.values(flatObjectMetadataMaps.byId) - .filter(isDefined) - .filter((obj) => obj.isActive && !obj.isSystem); - - const allObjectMetadata = allFlatObjects.map((flatObject) => ({ - ...flatObject, - fields: getFlatFieldsFromFlatObjectMetadata( - flatObject, - flatFieldMetadataMaps, - ), - })); - - let filteredObjectMetadata = allObjectMetadata.filter( - (objectMetadata) => !isWorkflowRelatedObject(objectMetadata), + const directRecordToolsFactory = createDirectRecordToolsFactory( + this.directRecordToolsDeps, ); - if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) { - const relevantSet = new Set(toolHints.relevantObjects); - const originalCount = filteredObjectMetadata.length; - - filteredObjectMetadata = filteredObjectMetadata.filter( - (obj) => - relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural), - ); - - this.logger.log( - `Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`, - ); - - if (filteredObjectMetadata.length === 0) { - this.logger.warn( - `Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`, - ); - } - } - - const operationsSet = toolHints?.operations - ? new Set(toolHints.operations) - : null; - - const shouldIncludeOperation = (operation: ToolOperation) => - !operationsSet || operationsSet.has(operation); - - const shouldIncludeFind = shouldIncludeOperation('find'); - const shouldIncludeCreate = shouldIncludeOperation('create'); - const shouldIncludeUpdate = shouldIncludeOperation('update'); - const shouldIncludeDelete = shouldIncludeOperation('delete'); - - filteredObjectMetadata.forEach((objectMetadata) => { - const objectPermission = objectPermissions[objectMetadata.id]; - - if (!objectPermission) { - return; - } - - const restrictedFields = objectPermission.restrictedFields; - - if (shouldIncludeFind && objectPermission.canReadObjectRecords) { - tools[`find_${objectMetadata.nameSingular}`] = { - description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`, - inputSchema: generateFindToolInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - const { limit, offset, orderBy, ...filter } = parameters.input; - - return this.findRecordsService.execute({ - objectName: objectMetadata.nameSingular, - filter, - orderBy, - limit, - offset, - workspaceId, - rolePermissionConfig, - }); - }, - }; - - tools[`find_one_${objectMetadata.nameSingular}`] = { - description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, - inputSchema: FindOneToolInputSchema, - execute: async (parameters) => { - return this.findRecordsService.execute({ - objectName: objectMetadata.nameSingular, - filter: { id: { eq: parameters.input.id } }, - limit: 1, - workspaceId, - rolePermissionConfig, - }); - }, - }; - } - - if (objectPermission.canUpdateObjectRecords) { - if (shouldIncludeCreate) { - tools[`create_${objectMetadata.nameSingular}`] = { - description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, - inputSchema: generateCreateRecordInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - return this.createRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecord: parameters.input, - workspaceId, - rolePermissionConfig, - createdBy: actorContext, - }); - }, - }; - } - - if (shouldIncludeUpdate) { - tools[`update_${objectMetadata.nameSingular}`] = { - description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, - inputSchema: generateUpdateRecordInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - const { id, ...allFields } = parameters.input; - - const objectRecord = Object.fromEntries( - Object.entries(allFields).filter( - ([, value]) => value !== undefined, - ), - ); - - return this.updateRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecordId: id, - objectRecord, - workspaceId, - rolePermissionConfig, - }); - }, - }; - } - } - - if (shouldIncludeDelete && objectPermission.canSoftDeleteObjectRecords) { - tools[`soft_delete_${objectMetadata.nameSingular}`] = { - description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`, - inputSchema: SoftDeleteToolInputSchema, - execute: async (parameters) => { - return this.deleteRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecordId: parameters.input.id, - workspaceId, - rolePermissionConfig, - soft: true, - }); - }, - }; - - tools[`soft_delete_many_${objectMetadata.nameSingular}`] = { - description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`, - inputSchema: BulkDeleteToolInputSchema, - execute: async (parameters) => { - return this.softDeleteManyRecords( - objectMetadata.nameSingular, - parameters.input, - workspaceId, - rolePermissionConfig, - ); - }, - }; - } - }); - - if (operationsSet) { - this.logger.log( - `Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`, - ); - } - - return tools; - } - - private async softDeleteManyRecords( - objectName: string, - parameters: Record, - workspaceId: string, - rolePermissionConfig: RolePermissionConfig, - ) { - try { - const repository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - objectName, - rolePermissionConfig, - ); - - const { filter } = parameters; - - if (!filter || typeof filter !== 'object' || !('id' in filter)) { - return { - success: false, - message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`, - error: 'Filter with record IDs is required for bulk soft delete', - }; - } - - const idFilter = filter.id as Record; - const recordIds = idFilter.in; - - if (!Array.isArray(recordIds) || recordIds.length === 0) { - return { - success: false, - message: `Failed to soft delete many ${objectName}: At least one record ID is required`, - error: 'At least one record ID is required for bulk soft delete', - }; - } - - const existingRecords = await repository.find({ - where: { id: { in: recordIds } }, - }); - - if (existingRecords.length === 0) { - return { - success: false, - message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`, - error: 'No records found to soft delete', - }; - } - - await repository.softDelete({ id: { in: recordIds } }); - - return { - success: true, - message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`, - result: { - count: existingRecords.length, - deletedIds: recordIds, - }, - }; - } catch (error) { - return { - success: false, - message: `Failed to soft delete many ${objectName}`, - error: error instanceof Error ? error.message : 'Unknown error', - }; - } + return this.perObjectToolGenerator.generate( + { + workspaceId, + rolePermissionConfig, + actorContext, + }, + [directRecordToolsFactory], + toolHints, + ); } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts new file mode 100644 index 0000000000..5246b67ba7 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum.ts @@ -0,0 +1,16 @@ +export enum WorkflowActionType { + CODE = 'CODE', + SEND_EMAIL = 'SEND_EMAIL', + CREATE_RECORD = 'CREATE_RECORD', + UPDATE_RECORD = 'UPDATE_RECORD', + DELETE_RECORD = 'DELETE_RECORD', + UPSERT_RECORD = 'UPSERT_RECORD', + FIND_RECORDS = 'FIND_RECORDS', + FORM = 'FORM', + FILTER = 'FILTER', + HTTP_REQUEST = 'HTTP_REQUEST', + AI_AGENT = 'AI_AGENT', + ITERATOR = 'ITERATOR', + EMPTY = 'EMPTY', + DELAY = 'DELAY', +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts index 5462384607..4a018efc3c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type.ts @@ -1,36 +1,25 @@ import { type WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type'; import { type WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type'; +import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type'; import { type WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type'; import { type WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type'; import { type WorkflowHttpRequestActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/http-request/types/workflow-http-request-action-settings.type'; import { type WorkflowIteratorActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/iterator/types/workflow-iterator-action-settings.type'; import { type WorkflowSendEmailActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/mail-sender/types/workflow-send-email-action-settings.type'; import { - type WorkflowUpsertRecordActionSettings, type WorkflowCreateRecordActionSettings, type WorkflowDeleteRecordActionSettings, type WorkflowFindRecordsActionSettings, type WorkflowUpdateRecordActionSettings, + type WorkflowUpsertRecordActionSettings, } from 'src/modules/workflow/workflow-executor/workflow-actions/record-crud/types/workflow-record-crud-action-settings.type'; import { type WorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type'; -import { type WorkflowDelayActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/delay/types/workflow-delay-action-settings.type'; -export enum WorkflowActionType { - CODE = 'CODE', - SEND_EMAIL = 'SEND_EMAIL', - CREATE_RECORD = 'CREATE_RECORD', - UPDATE_RECORD = 'UPDATE_RECORD', - DELETE_RECORD = 'DELETE_RECORD', - UPSERT_RECORD = 'UPSERT_RECORD', - FIND_RECORDS = 'FIND_RECORDS', - FORM = 'FORM', - FILTER = 'FILTER', - HTTP_REQUEST = 'HTTP_REQUEST', - AI_AGENT = 'AI_AGENT', - ITERATOR = 'ITERATOR', - EMPTY = 'EMPTY', - DELAY = 'DELAY', -} +// Import the enum from its dedicated file to avoid circular dependencies +import { WorkflowActionType } from './workflow-action-type.enum'; + +// Re-export for consumers +export { WorkflowActionType }; type BaseWorkflowAction = { id: string; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts new file mode 100644 index 0000000000..951446402e --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts @@ -0,0 +1,94 @@ +import { type ToolSet } from 'ai'; +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; +import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; + +import { + createAndConfigureStep, + type WorkflowStepToolsDeps, +} from './step-builder.utils'; + +export function buildCreateRecordStepTool( + deps: WorkflowStepToolsDeps, + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields: RestrictedFieldsPermissions, + context: ToolGeneratorContext, +): ToolSet { + const recordPropertiesSchema = generateRecordPropertiesZodSchema( + objectMetadata, + false, + restrictedFields, + ); + + const inputSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to add the step to'), + parentStepId: z + .string() + .optional() + .describe('Optional ID of the parent step this step should come after'), + stepName: z + .string() + .optional() + .describe( + `Name for this step (default: "Create ${objectMetadata.labelSingular}")`, + ), + input: recordPropertiesSchema.describe( + `The ${objectMetadata.labelSingular} record data. Use {{trigger.fieldName}} or {{stepId.fieldName}} syntax to reference dynamic values from previous steps.`, + ), + }); + + return { + [`configure_create_${objectMetadata.nameSingular}_step`]: { + description: + `Add a workflow step that creates a ${objectMetadata.labelSingular} record. ` + + `Provide the record fields directly - use {{trigger.fieldName}} or {{stepId.fieldName}} to reference values from previous steps.`, + inputSchema, + execute: async (parameters: z.infer) => { + try { + const { stepId, result } = await createAndConfigureStep( + deps, + context.workspaceId, + parameters.workflowVersionId, + WorkflowActionType.CREATE_RECORD, + parameters.parentStepId, + { + name: + parameters.stepName || `Create ${objectMetadata.labelSingular}`, + type: WorkflowActionType.CREATE_RECORD, + valid: true, + settings: { + input: { + objectName: objectMetadata.nameSingular, + objectRecord: parameters.input, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + }, + ); + + return { + success: true, + message: `Created workflow step to create ${objectMetadata.labelSingular}`, + result: { stepId, step: result }, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create ${objectMetadata.labelSingular} workflow step: ${error.message}`, + }; + } + }, + }, + }; +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts new file mode 100644 index 0000000000..a5993303e4 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts @@ -0,0 +1,87 @@ +import { type ToolSet } from 'ai'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; + +import { + createAndConfigureStep, + type WorkflowStepToolsDeps, +} from './step-builder.utils'; + +export function buildDeleteRecordStepTool( + deps: WorkflowStepToolsDeps, + objectMetadata: ObjectMetadataForToolSchema, + context: ToolGeneratorContext, +): ToolSet { + const inputSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to add the step to'), + parentStepId: z + .string() + .optional() + .describe('Optional ID of the parent step this step should come after'), + stepName: z + .string() + .optional() + .describe( + `Name for this step (default: "Delete ${objectMetadata.labelSingular}")`, + ), + objectRecordId: z + .string() + .describe( + `The ID of the ${objectMetadata.labelSingular} record to delete. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`, + ), + }); + + return { + [`configure_delete_${objectMetadata.nameSingular}_step`]: { + description: + `Add a workflow step that deletes a ${objectMetadata.labelSingular} record. ` + + `This performs a soft delete (marks as deleted but preserves data).`, + inputSchema, + execute: async (parameters: z.infer) => { + try { + const { stepId, result } = await createAndConfigureStep( + deps, + context.workspaceId, + parameters.workflowVersionId, + WorkflowActionType.DELETE_RECORD, + parameters.parentStepId, + { + name: + parameters.stepName || `Delete ${objectMetadata.labelSingular}`, + type: WorkflowActionType.DELETE_RECORD, + valid: true, + settings: { + input: { + objectName: objectMetadata.nameSingular, + objectRecordId: parameters.objectRecordId, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + }, + ); + + return { + success: true, + message: `Created workflow step to delete ${objectMetadata.labelSingular}`, + result: { stepId, step: result }, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create ${objectMetadata.labelSingular} delete workflow step: ${error.message}`, + }; + } + }, + }, + }; +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts new file mode 100644 index 0000000000..799ce6d820 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts @@ -0,0 +1,149 @@ +import { type ToolSet } from 'ai'; +import { + FieldMetadataType, + RelationType, + type RestrictedFieldsPermissions, +} from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; +import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema'; +import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; +import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; + +import { + createAndConfigureStep, + type WorkflowStepToolsDeps, +} from './step-builder.utils'; + +export function buildFindRecordsStepTool( + deps: WorkflowStepToolsDeps, + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields: RestrictedFieldsPermissions, + context: ToolGeneratorContext, +): ToolSet { + const filterShape: Record = {}; + + objectMetadata.fields.forEach((field) => { + if (shouldExcludeFieldFromAgentToolSchema(field)) { + return; + } + + if (restrictedFields?.[field.id]?.canRead === false) { + return; + } + + const filterSchema = generateFieldFilterZodSchema(field); + + if (!filterSchema) { + return; + } + + const isManyToOneRelationField = + isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + field.settings?.relationType === RelationType.MANY_TO_ONE; + + filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = + filterSchema; + }); + + const inputSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to add the step to'), + parentStepId: z + .string() + .optional() + .describe('Optional ID of the parent step this step should come after'), + stepName: z + .string() + .optional() + .describe( + `Name for this step (default: "Find ${objectMetadata.labelPlural}")`, + ), + limit: z + .number() + .int() + .positive() + .max(1000) + .optional() + .default(100) + .describe('Maximum number of records to return (default: 100)'), + orderBy: ObjectRecordOrderBySchema.optional().describe( + 'Sort records by field(s). Each item is an object with field name as key, sort direction as value.', + ), + filter: z + .object(filterShape) + .partial() + .optional() + .describe( + `Filter criteria for ${objectMetadata.labelPlural}. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`, + ), + }); + + return { + [`configure_find_${objectMetadata.namePlural}_step`]: { + description: + `Add a workflow step that searches for ${objectMetadata.labelPlural} records. ` + + `Results can be used in subsequent steps via {{stepId.result}}.`, + inputSchema, + execute: async (parameters: z.infer) => { + try { + const filterConfig = parameters.filter + ? { + recordFilters: Object.entries(parameters.filter).map( + ([fieldName, filterValue]) => ({ + fieldName, + filter: filterValue, + }), + ), + } + : undefined; + + const { stepId, result } = await createAndConfigureStep( + deps, + context.workspaceId, + parameters.workflowVersionId, + WorkflowActionType.FIND_RECORDS, + parameters.parentStepId, + { + name: parameters.stepName || `Find ${objectMetadata.labelPlural}`, + type: WorkflowActionType.FIND_RECORDS, + valid: true, + settings: { + input: { + objectName: objectMetadata.nameSingular, + limit: parameters.limit, + filter: filterConfig, + orderBy: parameters.orderBy + ? { gqlOperationOrderBy: parameters.orderBy } + : undefined, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + }, + ); + + return { + success: true, + message: `Created workflow step to find ${objectMetadata.labelPlural}`, + result: { stepId, step: result }, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create ${objectMetadata.labelPlural} find workflow step: ${error.message}`, + }; + } + }, + }, + }; +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts new file mode 100644 index 0000000000..588f5f0625 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts @@ -0,0 +1,52 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; + +export type WorkflowStepToolsDeps = { + workflowVersionStepService: { + createWorkflowVersionStep: (args: { + workspaceId: string; + input: { + workflowVersionId: string; + stepType: WorkflowActionType; + parentStepId?: string; + id?: string; + }; + }) => Promise; + updateWorkflowVersionStep: (args: { + workspaceId: string; + workflowVersionId: string; + step: unknown; + }) => Promise; + }; +}; + +export async function createAndConfigureStep( + deps: WorkflowStepToolsDeps, + workspaceId: string, + workflowVersionId: string, + stepType: WorkflowActionType, + parentStepId: string | undefined, + stepConfig: object, +) { + const stepId = uuidv4(); + + await deps.workflowVersionStepService.createWorkflowVersionStep({ + workspaceId, + input: { + workflowVersionId, + stepType, + parentStepId, + id: stepId, + }, + }); + + const result = + await deps.workflowVersionStepService.updateWorkflowVersionStep({ + workspaceId, + workflowVersionId, + step: { id: stepId, ...stepConfig }, + }); + + return { stepId, result }; +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts new file mode 100644 index 0000000000..a766e25954 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts @@ -0,0 +1,101 @@ +import { type ToolSet } from 'ai'; +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; +import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; + +import { + createAndConfigureStep, + type WorkflowStepToolsDeps, +} from './step-builder.utils'; + +export function buildUpdateRecordStepTool( + deps: WorkflowStepToolsDeps, + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields: RestrictedFieldsPermissions, + context: ToolGeneratorContext, +): ToolSet { + const recordPropertiesSchema = generateRecordPropertiesZodSchema( + objectMetadata, + false, + restrictedFields, + ); + const updateSchema = recordPropertiesSchema.partial(); + + const inputSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to add the step to'), + parentStepId: z + .string() + .optional() + .describe('Optional ID of the parent step this step should come after'), + stepName: z + .string() + .optional() + .describe( + `Name for this step (default: "Update ${objectMetadata.labelSingular}")`, + ), + objectRecordId: z + .string() + .describe( + `The ID of the ${objectMetadata.labelSingular} record to update. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`, + ), + fieldsToUpdate: updateSchema.describe( + `The fields to update on the ${objectMetadata.labelSingular} record. Only include fields you want to change. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`, + ), + }); + + return { + [`configure_update_${objectMetadata.nameSingular}_step`]: { + description: + `Add a workflow step that updates an existing ${objectMetadata.labelSingular} record. ` + + `Specify the record ID and only the fields you want to update.`, + inputSchema, + execute: async (parameters: z.infer) => { + try { + const { stepId, result } = await createAndConfigureStep( + deps, + context.workspaceId, + parameters.workflowVersionId, + WorkflowActionType.UPDATE_RECORD, + parameters.parentStepId, + { + name: + parameters.stepName || `Update ${objectMetadata.labelSingular}`, + type: WorkflowActionType.UPDATE_RECORD, + valid: true, + settings: { + input: { + objectName: objectMetadata.nameSingular, + objectRecordId: parameters.objectRecordId, + objectRecord: parameters.fieldsToUpdate, + }, + outputSchema: {}, + errorHandlingOptions: { + retryOnFailure: { value: false }, + continueOnFailure: { value: false }, + }, + }, + }, + ); + + return { + success: true, + message: `Created workflow step to update ${objectMetadata.labelSingular}`, + result: { stepId, step: result }, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create ${objectMetadata.labelSingular} update workflow step: ${error.message}`, + }; + } + }, + }, + }; +} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts new file mode 100644 index 0000000000..c97714fa54 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts @@ -0,0 +1,75 @@ +import { type ToolSet } from 'ai'; + +import { + type ObjectWithPermission, + type ToolGeneratorContext, +} from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; + +import { buildCreateRecordStepTool } from './builders/create-record-step.builder'; +import { buildDeleteRecordStepTool } from './builders/delete-record-step.builder'; +import { buildFindRecordsStepTool } from './builders/find-records-step.builder'; +import { type WorkflowStepToolsDeps } from './builders/step-builder.utils'; +import { buildUpdateRecordStepTool } from './builders/update-record-step.builder'; + +export { type WorkflowStepToolsDeps } from './builders/step-builder.utils'; + +export const createWorkflowStepToolsFactory = (deps: WorkflowStepToolsDeps) => { + return ( + { + objectMetadata, + restrictedFields, + canCreate, + canRead, + canUpdate, + canDelete, + }: ObjectWithPermission, + context: ToolGeneratorContext, + ): ToolSet => { + const tools: ToolSet = {}; + + if (canCreate) { + Object.assign( + tools, + buildCreateRecordStepTool( + deps, + objectMetadata, + restrictedFields, + context, + ), + ); + } + + if (canUpdate) { + Object.assign( + tools, + buildUpdateRecordStepTool( + deps, + objectMetadata, + restrictedFields, + context, + ), + ); + } + + if (canRead) { + Object.assign( + tools, + buildFindRecordsStepTool( + deps, + objectMetadata, + restrictedFields, + context, + ), + ); + } + + if (canDelete) { + Object.assign( + tools, + buildDeleteRecordStepTool(deps, objectMetadata, context), + ); + } + + return tools; + }; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas.ts deleted file mode 100644 index d84bf0913d..0000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { - workflowActionSchema, - workflowTriggerSchema, -} from 'twenty-shared/workflow'; -import { z } from 'zod'; - -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; - -export const createWorkflowVersionStepSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to add the step to'), - stepType: z - .enum(Object.values(WorkflowActionType) as [string, ...string[]]) - .describe('The type of step to create'), - parentStepId: z - .string() - .optional() - .describe('Optional ID of the parent step this step should come after'), - nextStepId: z - .string() - .optional() - .describe('Optional ID of the step this new step should connect to'), - position: z - .object({ - x: z.number(), - y: z.number(), - }) - .optional() - .describe('Optional position coordinates for the step'), -}); - -export const updateWorkflowVersionStepSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version containing the step'), - step: z - .union([workflowTriggerSchema, workflowActionSchema]) - .describe('The updated step configuration'), -}); - -export const deleteWorkflowVersionStepSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version containing the step'), - stepId: z.string().describe('The ID of the step to delete'), -}); - -export const createWorkflowVersionEdgeSchema = z.object({ - workflowVersionId: z.string().describe('The ID of the workflow version'), - source: z.string().describe('The ID of the source step'), - target: z.string().describe('The ID of the target step'), -}); - -export const deleteWorkflowVersionEdgeSchema = z.object({ - workflowVersionId: z.string().describe('The ID of the workflow version'), - source: z.string().describe('The ID of the source step'), - target: z.string().describe('The ID of the target step'), -}); - -export const createDraftFromWorkflowVersionSchema = z.object({ - workflowId: z.string().describe('The ID of the workflow'), - workflowVersionIdToCopy: z - .string() - .describe('The ID of the workflow version to create a draft from'), -}); - -export const updateWorkflowVersionPositionsSchema = z.object({ - workflowVersionId: z.string().describe('The ID of the workflow version'), - positions: z - .array( - z.object({ - stepId: z.string(), - position: z.object({ - x: z.number(), - y: z.number(), - }), - }), - ) - .describe('Array of step positions to update'), -}); - -export const activateWorkflowVersionSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to activate'), -}); - -export const deactivateWorkflowVersionSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to deactivate'), -}); - -export const computeStepOutputSchemaSchema = z.object({ - step: z - .union([workflowTriggerSchema, workflowActionSchema]) - .describe('The workflow step configuration'), - workflowVersionId: z.string().describe('The ID of the workflow version'), -}); - -export const createCompleteWorkflowSchema = z.object({ - name: z.string().describe('The name of the workflow'), - description: z - .string() - .optional() - .describe('Optional description of the workflow'), - trigger: workflowTriggerSchema, - steps: z - .array(workflowActionSchema) - .describe('Array of workflow action steps'), - stepPositions: z - .array( - z.object({ - stepId: z - .string() - .describe('The ID of the step (use "trigger" for trigger step)'), - position: z.object({ - x: z.number().describe('X coordinate for the step position'), - y: z.number().describe('Y coordinate for the step position'), - }), - }), - ) - .optional() - .describe('Optional array of step positions for layout'), - edges: z - .array( - z.object({ - source: z - .string() - .describe( - 'The ID of the source step (use "trigger" for trigger step)', - ), - target: z.string().describe('The ID of the target step'), - }), - ) - .optional() - .describe('Optional array of connections between steps'), - activate: z - .boolean() - .optional() - .describe('Whether to activate the workflow immediately (default: false)'), -}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts index 307b201dbd..b1f2c12ccf 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts @@ -1,510 +1,143 @@ import { Injectable } from '@nestjs/common'; import { type ToolSet } from 'ai'; -import { v4 as uuidv4 } from 'uuid'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; -import type { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; -import type { UpdateWorkflowVersionPositionsInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto'; -import type { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto'; +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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; -import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; -import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; -import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { - activateWorkflowVersionSchema, - computeStepOutputSchemaSchema, - createCompleteWorkflowSchema, - createDraftFromWorkflowVersionSchema, - createWorkflowVersionEdgeSchema, - createWorkflowVersionStepSchema, - deactivateWorkflowVersionSchema, - deleteWorkflowVersionEdgeSchema, - deleteWorkflowVersionStepSchema, - updateWorkflowVersionPositionsSchema, - updateWorkflowVersionStepSchema, -} from 'src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas'; -import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + createWorkflowStepToolsFactory, + type WorkflowStepToolsDeps, +} from 'src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory'; +import { createActivateWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool'; +import { createComputeStepOutputSchemaTool } from 'src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool'; +import { createCreateCompleteWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool'; +import { createCreateDraftFromWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/create-draft-from-workflow-version.tool'; +import { createCreateWorkflowVersionEdgeTool } from 'src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool'; +import { createCreateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool'; +import { createDeactivateWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/deactivate-workflow-version.tool'; +import { createDeleteWorkflowVersionEdgeTool } from 'src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool'; +import { createDeleteWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool'; +import { createUpdateWorkflowVersionPositionsTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool'; +import { createUpdateWorkflowVersionStepTool } from 'src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool'; +import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; @Injectable() export class WorkflowToolWorkspaceService { - constructor( - private readonly workflowVersionStepService: WorkflowVersionStepWorkspaceService, - private readonly workflowVersionEdgeService: WorkflowVersionEdgeWorkspaceService, - private readonly workflowVersionService: WorkflowVersionWorkspaceService, - private readonly workflowTriggerService: WorkflowTriggerWorkspaceService, - private readonly workflowSchemaService: WorkflowSchemaWorkspaceService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly recordPositionService: RecordPositionService, - ) {} + private readonly deps: WorkflowToolDependencies; + private readonly workflowStepToolsDeps: WorkflowStepToolsDeps; + constructor( + workflowVersionStepService: WorkflowVersionStepWorkspaceService, + workflowVersionEdgeService: WorkflowVersionEdgeWorkspaceService, + workflowVersionService: WorkflowVersionWorkspaceService, + workflowTriggerService: WorkflowTriggerWorkspaceService, + workflowSchemaService: WorkflowSchemaWorkspaceService, + twentyORMGlobalManager: TwentyORMGlobalManager, + recordPositionService: RecordPositionService, + private readonly perObjectToolGenerator: PerObjectToolGeneratorService, + ) { + this.deps = { + workflowVersionStepService, + workflowVersionEdgeService, + workflowVersionService, + workflowTriggerService, + workflowSchemaService, + twentyORMGlobalManager, + recordPositionService, + }; + + this.workflowStepToolsDeps = { + workflowVersionStepService, + }; + } + + // Generates static workflow tools that don't depend on workspace objects generateWorkflowTools( workspaceId: string, rolePermissionConfig: RolePermissionConfig, ): ToolSet { - const tools: ToolSet = {}; + const context = { workspaceId }; + const contextWithPermissions = { workspaceId, rolePermissionConfig }; - tools.create_complete_workflow = { - description: `Create a complete workflow with trigger, steps, and connections in a single operation. + const createCompleteWorkflow = createCreateCompleteWorkflowTool( + this.deps, + contextWithPermissions, + ); + const createWorkflowVersionStep = createCreateWorkflowVersionStepTool( + this.deps, + context, + ); + const updateWorkflowVersionStep = createUpdateWorkflowVersionStepTool( + this.deps, + context, + ); + const deleteWorkflowVersionStep = createDeleteWorkflowVersionStepTool( + this.deps, + context, + ); + const createWorkflowVersionEdge = createCreateWorkflowVersionEdgeTool( + this.deps, + context, + ); + const deleteWorkflowVersionEdge = createDeleteWorkflowVersionEdgeTool( + this.deps, + context, + ); + const createDraftFromWorkflowVersion = + createCreateDraftFromWorkflowVersionTool(this.deps, context); + const updateWorkflowVersionPositions = + createUpdateWorkflowVersionPositionsTool(this.deps, context); + const activateWorkflowVersion = createActivateWorkflowVersionTool( + this.deps, + ); + const deactivateWorkflowVersion = createDeactivateWorkflowVersionTool( + this.deps, + ); + const computeStepOutputSchema = createComputeStepOutputSchemaTool( + this.deps, + context, + ); -CRITICAL SCHEMA REQUIREMENTS: -- Trigger type MUST be one of: DATABASE_EVENT, MANUAL, CRON, WEBHOOK -- NEVER use "RECORD_CREATED" - this is invalid. Use "DATABASE_EVENT" instead. -- Each step MUST include: id, name, type, valid, settings -- CREATE_RECORD actions MUST have objectName and objectRecord in settings.input -- objectRecord must contain actual field values, not just field names -- Use "trigger" as stepId for trigger step in stepPositions and edges - -Common mistakes to avoid: -- Using "RECORD_CREATED" instead of "DATABASE_EVENT" -- Missing the "name" and "valid" fields in steps -- Missing the "objectRecord" field in CREATE_RECORD actions -- Using "fieldsToUpdate" instead of "objectRecord" in CREATE_RECORD actions - -IMPORTANT: The tool schema provides comprehensive field descriptions, examples, and validation rules. Always refer to the schema for: -- Field requirements and data types -- Common object patterns and field structures -- Proper relationship field formats -- Variable reference syntax (e.g., {{trigger.object.fieldName}}) -- Error handling options - -This is the most efficient way for AI to create workflows as it handles all the complexity in one call.`, - inputSchema: createCompleteWorkflowSchema, - execute: async (parameters: { - name: string; - description?: string; - trigger: WorkflowTrigger; - steps: WorkflowAction[]; - stepPositions?: Array<{ - stepId: string; - position: { x: number; y: number }; - }>; - edges?: Array<{ source: string; target: string }>; - activate?: boolean; - }) => { - try { - const workflowId = await this.createWorkflow({ - workspaceId, - name: parameters.name, - rolePermissionConfig, - }); - - const workflowVersionId = await this.createWorkflowVersion({ - workspaceId, - workflowId, - trigger: parameters.trigger, - steps: parameters.steps, - rolePermissionConfig, - }); - - if (parameters.stepPositions && parameters.stepPositions.length > 0) { - const positions = parameters.stepPositions.map((pos) => ({ - id: pos.stepId === 'trigger' ? 'trigger' : pos.stepId, - position: pos.position, - })); - - await this.workflowVersionService.updateWorkflowVersionPositions({ - workflowVersionId, - positions, - workspaceId, - }); - } - - if (parameters.edges && parameters.edges.length > 0) { - for (const edge of parameters.edges) { - await this.workflowVersionEdgeService.createWorkflowVersionEdge({ - source: edge.source === 'trigger' ? 'trigger' : edge.source, - target: edge.target, - workflowVersionId, - workspaceId, - }); - } - } - - if (parameters.activate) { - await this.workflowTriggerService.activateWorkflowVersion( - workflowVersionId, - ); - - await this.updateWorkflowStatus({ - workspaceId, - workflowId, - workflowVersionId, - rolePermissionConfig, - }); - } - - return { - success: true, - message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`, - result: { - workflowId, - workflowVersionId, - name: parameters.name, - trigger: parameters.trigger, - steps: parameters.steps, - }, - }; - } catch (error) { - return { - success: false, - message: `Failed to create workflow "${parameters.name}": ${error.message}`, - error: error.message, - }; - } - }, + return { + [createCompleteWorkflow.name]: createCompleteWorkflow, + [createWorkflowVersionStep.name]: createWorkflowVersionStep, + [updateWorkflowVersionStep.name]: updateWorkflowVersionStep, + [deleteWorkflowVersionStep.name]: deleteWorkflowVersionStep, + [createWorkflowVersionEdge.name]: createWorkflowVersionEdge, + [deleteWorkflowVersionEdge.name]: deleteWorkflowVersionEdge, + [createDraftFromWorkflowVersion.name]: createDraftFromWorkflowVersion, + [updateWorkflowVersionPositions.name]: updateWorkflowVersionPositions, + [activateWorkflowVersion.name]: activateWorkflowVersion, + [deactivateWorkflowVersion.name]: deactivateWorkflowVersion, + [computeStepOutputSchema.name]: computeStepOutputSchema, }; - - tools.create_workflow_version_step = { - description: - 'Create a new step in a workflow version. This adds a step to the specified workflow version with the given configuration.', - inputSchema: createWorkflowVersionStepSchema, - execute: async (parameters: CreateWorkflowVersionStepInput) => { - try { - return await this.workflowVersionStepService.createWorkflowVersionStep( - { - workspaceId, - input: parameters, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create workflow version step: ${error.message}`, - }; - } - }, - }; - - tools.update_workflow_version_step = { - description: - 'Update an existing step in a workflow version. This modifies the step configuration.', - inputSchema: updateWorkflowVersionStepSchema, - execute: async (parameters: UpdateWorkflowVersionStepInput) => { - try { - return await this.workflowVersionStepService.updateWorkflowVersionStep( - { - workspaceId, - workflowVersionId: parameters.workflowVersionId, - step: parameters.step, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to update workflow version step: ${error.message}`, - }; - } - }, - }; - - tools.delete_workflow_version_step = { - description: - 'Delete a step from a workflow version. This removes the step and updates the workflow structure.', - inputSchema: deleteWorkflowVersionStepSchema, - execute: async (parameters: { - workflowVersionId: string; - stepId: string; - }) => { - try { - return await this.workflowVersionStepService.deleteWorkflowVersionStep( - { - workspaceId, - workflowVersionId: parameters.workflowVersionId, - stepIdToDelete: parameters.stepId, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to delete workflow version step: ${error.message}`, - }; - } - }, - }; - - tools.create_workflow_version_edge = { - description: - 'Create a connection (edge) between two workflow steps. This defines the flow between steps.', - inputSchema: createWorkflowVersionEdgeSchema, - execute: async (parameters: { - workflowVersionId: string; - source: string; - target: string; - }) => { - try { - return await this.workflowVersionEdgeService.createWorkflowVersionEdge( - { - source: parameters.source, - target: parameters.target, - workflowVersionId: parameters.workflowVersionId, - workspaceId, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create workflow version edge: ${error.message}`, - }; - } - }, - }; - - tools.delete_workflow_version_edge = { - description: 'Delete a connection (edge) between workflow steps.', - inputSchema: deleteWorkflowVersionEdgeSchema, - execute: async (parameters: { - workflowVersionId: string; - source: string; - target: string; - }) => { - try { - return await this.workflowVersionEdgeService.deleteWorkflowVersionEdge( - { - source: parameters.source, - target: parameters.target, - workflowVersionId: parameters.workflowVersionId, - workspaceId, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to delete workflow version edge: ${error.message}`, - }; - } - }, - }; - - tools.create_draft_from_workflow_version = { - description: - 'Create a new draft workflow version from an existing one. This allows for iterative workflow development.', - inputSchema: createDraftFromWorkflowVersionSchema, - execute: async (parameters: { - workflowId: string; - workflowVersionIdToCopy: string; - }) => { - try { - return await this.workflowVersionService.createDraftFromWorkflowVersion( - { - workspaceId, - workflowId: parameters.workflowId, - workflowVersionIdToCopy: parameters.workflowVersionIdToCopy, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create draft from workflow version: ${error.message}`, - }; - } - }, - }; - - tools.update_workflow_version_positions = { - description: - 'Update the positions of multiple workflow steps. This is useful for reorganizing the workflow layout.', - inputSchema: updateWorkflowVersionPositionsSchema, - execute: async (parameters: UpdateWorkflowVersionPositionsInput) => { - try { - return await this.workflowVersionService.updateWorkflowVersionPositions( - { - workflowVersionId: parameters.workflowVersionId, - positions: parameters.positions, - workspaceId, - }, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to update workflow version step positions: ${error.message}`, - }; - } - }, - }; - - tools.activate_workflow_version = { - description: - 'Activate a workflow version. This makes the workflow version active and available for execution.', - inputSchema: activateWorkflowVersionSchema, - execute: async (parameters: { workflowVersionId: string }) => { - try { - return await this.workflowTriggerService.activateWorkflowVersion( - parameters.workflowVersionId, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to activate workflow version: ${error.message}`, - }; - } - }, - }; - - tools.deactivate_workflow_version = { - description: - 'Deactivate a workflow version. This makes the workflow version inactive and unavailable for execution.', - inputSchema: deactivateWorkflowVersionSchema, - execute: async (parameters: { workflowVersionId: string }) => { - try { - return await this.workflowTriggerService.deactivateWorkflowVersion( - parameters.workflowVersionId, - ); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to deactivate workflow version: ${error.message}`, - }; - } - }, - }; - - tools.compute_step_output_schema = { - description: - 'Compute the output schema for a workflow step. This determines what data the step produces. The step parameter must be a valid WorkflowTrigger or WorkflowAction with the correct settings structure for its type.', - inputSchema: computeStepOutputSchemaSchema, - execute: async (parameters: { - step: WorkflowTrigger | WorkflowAction; - workflowVersionId: string; - }) => { - try { - return await this.workflowSchemaService.computeStepOutputSchema({ - step: parameters.step, - workspaceId, - workflowVersionId: parameters.workflowVersionId, - }); - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to compute step output schema: ${error.message}`, - }; - } - }, - }; - - return tools; } - private async createWorkflow({ - workspaceId, - name, - rolePermissionConfig, - }: { - workspaceId: string; - name: string; - rolePermissionConfig: RolePermissionConfig; - }): Promise { - const workflowRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( + // Generates dynamic step configurator tools for each workspace object + async generateRecordStepConfiguratorTools( + workspaceId: string, + rolePermissionConfig: RolePermissionConfig, + toolHints?: ToolHints, + ): Promise { + const workflowStepToolsFactory = createWorkflowStepToolsFactory( + this.workflowStepToolsDeps, + ); + + return this.perObjectToolGenerator.generate( + { workspaceId, - 'workflow', rolePermissionConfig, - ); - - const workflowPosition = - await this.recordPositionService.buildRecordPosition({ - value: 'first', - objectMetadata: { - isCustom: false, - nameSingular: 'workflow', - }, - workspaceId, - }); - - const workflow = { - id: uuidv4(), - name, - statuses: [WorkflowStatus.DRAFT], - position: workflowPosition, - }; - - await workflowRepository.insert(workflow); - - return workflow.id; - } - - private async createWorkflowVersion({ - workspaceId, - workflowId, - trigger, - steps, - rolePermissionConfig, - }: { - workspaceId: string; - workflowId: string; - trigger: WorkflowTrigger; - steps: WorkflowAction[]; - rolePermissionConfig: RolePermissionConfig; - }): Promise { - const workflowVersionRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'workflowVersion', - rolePermissionConfig, - ); - - const versionPosition = - await this.recordPositionService.buildRecordPosition({ - value: 'first', - objectMetadata: { - isCustom: false, - nameSingular: 'workflowVersion', - }, - workspaceId, - }); - - const workflowVersion = { - id: uuidv4(), - workflowId, - name: 'v1', - status: WorkflowVersionStatus.DRAFT, - trigger, - steps, - position: versionPosition, - }; - - await workflowVersionRepository.insert(workflowVersion); - - return workflowVersion.id; - } - - private async updateWorkflowStatus({ - workspaceId, - workflowId, - workflowVersionId, - rolePermissionConfig, - }: { - workspaceId: string; - workflowId: string; - workflowVersionId: string; - rolePermissionConfig: RolePermissionConfig; - }) { - const workflowRepository = - await this.twentyORMGlobalManager.getRepositoryForWorkspace( - workspaceId, - 'workflow', - rolePermissionConfig, - ); - - await workflowRepository.update(workflowId, { - statuses: [WorkflowStatus.ACTIVE], - lastPublishedVersionId: workflowVersionId, - }); + }, + [workflowStepToolsFactory], + toolHints, + ); } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool.ts new file mode 100644 index 0000000000..fcf8221db9 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const activateWorkflowVersionSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to activate'), +}); + +type ActivateWorkflowVersionInput = z.infer< + typeof activateWorkflowVersionSchema +>; + +export const createActivateWorkflowVersionTool = ( + deps: Pick, +) => ({ + name: 'activate_workflow_version' as const, + description: + 'Activate a workflow version. This makes the workflow version active and available for execution.', + inputSchema: activateWorkflowVersionSchema, + execute: async (parameters: ActivateWorkflowVersionInput) => { + try { + return await deps.workflowTriggerService.activateWorkflowVersion( + parameters.workflowVersionId, + ); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to activate workflow version: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool.ts new file mode 100644 index 0000000000..e7dff05051 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool.ts @@ -0,0 +1,47 @@ +import { + workflowActionSchema, + workflowTriggerSchema, +} from 'twenty-shared/workflow'; +import { z } from 'zod'; + +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; +import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +const computeStepOutputSchemaSchema = z.object({ + step: z + .union([workflowTriggerSchema, workflowActionSchema]) + .describe('The workflow step configuration'), + workflowVersionId: z.string().describe('The ID of the workflow version'), +}); + +export const createComputeStepOutputSchemaTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'compute_step_output_schema' as const, + description: + 'Compute the output schema for a workflow step. This determines what data the step produces. The step parameter must be a valid WorkflowTrigger or WorkflowAction with the correct settings structure for its type.', + inputSchema: computeStepOutputSchemaSchema, + execute: async (parameters: { + step: WorkflowTrigger | WorkflowAction; + workflowVersionId: string; + }) => { + try { + return await deps.workflowSchemaService.computeStepOutputSchema({ + step: parameters.step, + workspaceId: context.workspaceId, + workflowVersionId: parameters.workflowVersionId, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to compute step output schema: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts new file mode 100644 index 0000000000..062c32a15c --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts @@ -0,0 +1,294 @@ +import { + workflowActionSchema, + workflowTriggerSchema, +} from 'twenty-shared/workflow'; +import { v4 as uuidv4 } from 'uuid'; +import { z } from 'zod'; + +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { WorkflowVersionStatus } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; +import { WorkflowStatus } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; +import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; +import { type WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +const createCompleteWorkflowSchema = z.object({ + name: z.string().describe('The name of the workflow'), + description: z + .string() + .optional() + .describe('Optional description of the workflow'), + trigger: workflowTriggerSchema, + steps: z + .array(workflowActionSchema) + .describe('Array of workflow action steps'), + stepPositions: z + .array( + z.object({ + stepId: z + .string() + .describe('The ID of the step (use "trigger" for trigger step)'), + position: z.object({ + x: z.number().describe('X coordinate for the step position'), + y: z.number().describe('Y coordinate for the step position'), + }), + }), + ) + .optional() + .describe('Optional array of step positions for layout'), + edges: z + .array( + z.object({ + source: z + .string() + .describe( + 'The ID of the source step (use "trigger" for trigger step)', + ), + target: z.string().describe('The ID of the target step'), + }), + ) + .optional() + .describe('Optional array of connections between steps'), + activate: z + .boolean() + .optional() + .describe('Whether to activate the workflow immediately (default: false)'), +}); + +type CreateCompleteWorkflowToolDeps = Pick< + WorkflowToolDependencies, + | 'workflowVersionService' + | 'workflowVersionEdgeService' + | 'workflowTriggerService' + | 'twentyORMGlobalManager' + | 'recordPositionService' +>; + +type CreateCompleteWorkflowToolContext = WorkflowToolContext & { + rolePermissionConfig: RolePermissionConfig; +}; + +export const createCreateCompleteWorkflowTool = ( + deps: CreateCompleteWorkflowToolDeps, + context: CreateCompleteWorkflowToolContext, +) => ({ + name: 'create_complete_workflow' as const, + description: `Create a complete workflow with trigger, steps, and connections in a single operation. + +CRITICAL SCHEMA REQUIREMENTS: +- Trigger type MUST be one of: DATABASE_EVENT, MANUAL, CRON, WEBHOOK +- NEVER use "RECORD_CREATED" - this is invalid. Use "DATABASE_EVENT" instead. +- Each step MUST include: id, name, type, valid, settings +- CREATE_RECORD actions MUST have objectName and objectRecord in settings.input +- objectRecord must contain actual field values, not just field names +- Use "trigger" as stepId for trigger step in stepPositions and edges + +Common mistakes to avoid: +- Using "RECORD_CREATED" instead of "DATABASE_EVENT" +- Missing the "name" and "valid" fields in steps +- Missing the "objectRecord" field in CREATE_RECORD actions +- Using "fieldsToUpdate" instead of "objectRecord" in CREATE_RECORD actions + +IMPORTANT: The tool schema provides comprehensive field descriptions, examples, and validation rules. Always refer to the schema for: +- Field requirements and data types +- Common object patterns and field structures +- Proper relationship field formats +- Variable reference syntax (e.g., {{trigger.object.fieldName}}) +- Error handling options + +This is the most efficient way for AI to create workflows as it handles all the complexity in one call.`, + inputSchema: createCompleteWorkflowSchema, + execute: async (parameters: { + name: string; + description?: string; + trigger: WorkflowTrigger; + steps: WorkflowAction[]; + stepPositions?: Array<{ + stepId: string; + position: { x: number; y: number }; + }>; + edges?: Array<{ source: string; target: string }>; + activate?: boolean; + }) => { + try { + const workflowId = await createWorkflow({ + deps, + context, + name: parameters.name, + }); + + const workflowVersionId = await createWorkflowVersion({ + deps, + context, + workflowId, + trigger: parameters.trigger, + steps: parameters.steps, + }); + + if (parameters.stepPositions && parameters.stepPositions.length > 0) { + const positions = parameters.stepPositions.map((pos) => ({ + id: pos.stepId === 'trigger' ? 'trigger' : pos.stepId, + position: pos.position, + })); + + await deps.workflowVersionService.updateWorkflowVersionPositions({ + workflowVersionId, + positions, + workspaceId: context.workspaceId, + }); + } + + if (parameters.edges && parameters.edges.length > 0) { + for (const edge of parameters.edges) { + await deps.workflowVersionEdgeService.createWorkflowVersionEdge({ + source: edge.source === 'trigger' ? 'trigger' : edge.source, + target: edge.target, + workflowVersionId, + workspaceId: context.workspaceId, + }); + } + } + + if (parameters.activate) { + await deps.workflowTriggerService.activateWorkflowVersion( + workflowVersionId, + ); + + await updateWorkflowStatus({ + deps, + context, + workflowId, + workflowVersionId, + }); + } + + return { + success: true, + message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`, + result: { + workflowId, + workflowVersionId, + name: parameters.name, + trigger: parameters.trigger, + steps: parameters.steps, + }, + }; + } catch (error) { + return { + success: false, + message: `Failed to create workflow "${parameters.name}": ${error.message}`, + error: error.message, + }; + } + }, +}); + +const createWorkflow = async ({ + deps, + context, + name, +}: { + deps: CreateCompleteWorkflowToolDeps; + context: CreateCompleteWorkflowToolContext; + name: string; +}): Promise => { + const workflowRepository = + await deps.twentyORMGlobalManager.getRepositoryForWorkspace( + context.workspaceId, + 'workflow', + context.rolePermissionConfig, + ); + + const workflowPosition = await deps.recordPositionService.buildRecordPosition( + { + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflow', + }, + workspaceId: context.workspaceId, + }, + ); + + const workflow = { + id: uuidv4(), + name, + statuses: [WorkflowStatus.DRAFT], + position: workflowPosition, + }; + + await workflowRepository.insert(workflow); + + return workflow.id; +}; + +const createWorkflowVersion = async ({ + deps, + context, + workflowId, + trigger, + steps, +}: { + deps: CreateCompleteWorkflowToolDeps; + context: CreateCompleteWorkflowToolContext; + workflowId: string; + trigger: WorkflowTrigger; + steps: WorkflowAction[]; +}): Promise => { + const workflowVersionRepository = + await deps.twentyORMGlobalManager.getRepositoryForWorkspace( + context.workspaceId, + 'workflowVersion', + context.rolePermissionConfig, + ); + + const versionPosition = await deps.recordPositionService.buildRecordPosition({ + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflowVersion', + }, + workspaceId: context.workspaceId, + }); + + const workflowVersion = { + id: uuidv4(), + workflowId, + name: 'v1', + status: WorkflowVersionStatus.DRAFT, + trigger, + steps, + position: versionPosition, + }; + + await workflowVersionRepository.insert(workflowVersion); + + return workflowVersion.id; +}; + +const updateWorkflowStatus = async ({ + deps, + context, + workflowId, + workflowVersionId, +}: { + deps: CreateCompleteWorkflowToolDeps; + context: CreateCompleteWorkflowToolContext; + workflowId: string; + workflowVersionId: string; +}) => { + const workflowRepository = + await deps.twentyORMGlobalManager.getRepositoryForWorkspace( + context.workspaceId, + 'workflow', + context.rolePermissionConfig, + ); + + await workflowRepository.update(workflowId, { + statuses: [WorkflowStatus.ACTIVE], + lastPublishedVersionId: workflowVersionId, + }); +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-draft-from-workflow-version.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-draft-from-workflow-version.tool.ts new file mode 100644 index 0000000000..9b4a72296a --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-draft-from-workflow-version.tool.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const createDraftFromWorkflowVersionSchema = z.object({ + workflowId: z.string().describe('The ID of the workflow'), + workflowVersionIdToCopy: z + .string() + .describe('The ID of the workflow version to create a draft from'), +}); + +type CreateDraftFromWorkflowVersionInput = z.infer< + typeof createDraftFromWorkflowVersionSchema +>; + +export const createCreateDraftFromWorkflowVersionTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'create_draft_from_workflow_version' as const, + description: + 'Create a new draft workflow version from an existing one. This allows for iterative workflow development.', + inputSchema: createDraftFromWorkflowVersionSchema, + execute: async (parameters: CreateDraftFromWorkflowVersionInput) => { + try { + return await deps.workflowVersionService.createDraftFromWorkflowVersion({ + workspaceId: context.workspaceId, + workflowId: parameters.workflowId, + workflowVersionIdToCopy: parameters.workflowVersionIdToCopy, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create draft from workflow version: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts new file mode 100644 index 0000000000..adf1e15f47 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-edge.tool.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const createWorkflowVersionEdgeSchema = z.object({ + workflowVersionId: z.string().describe('The ID of the workflow version'), + source: z.string().describe('The ID of the source step'), + target: z.string().describe('The ID of the target step'), + sourceConnectionOptions: z + .object({ + connectedStepType: z.literal(WorkflowActionType.ITERATOR), + settings: z.object({ + isConnectedToLoop: z.boolean(), + }), + }) + .optional() + .describe('Optional connection options for iterator steps'), +}); + +type CreateWorkflowVersionEdgeInput = z.infer< + typeof createWorkflowVersionEdgeSchema +>; + +export const createCreateWorkflowVersionEdgeTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'create_workflow_version_edge' as const, + description: + 'Create a connection (edge) between two workflow steps. This defines the flow between steps.', + inputSchema: createWorkflowVersionEdgeSchema, + execute: async (parameters: CreateWorkflowVersionEdgeInput) => { + try { + return await deps.workflowVersionEdgeService.createWorkflowVersionEdge({ + source: parameters.source, + target: parameters.target, + workflowVersionId: parameters.workflowVersionId, + workspaceId: context.workspaceId, + sourceConnectionOptions: parameters.sourceConnectionOptions, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create workflow version edge: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts new file mode 100644 index 0000000000..4f71b028e2 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +import type { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const createWorkflowVersionStepSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to add the step to'), + stepType: z + .enum(Object.values(WorkflowActionType) as [string, ...string[]]) + .describe('The type of step to create'), + parentStepId: z + .string() + .optional() + .describe('Optional ID of the parent step this step should come after'), + parentStepConnectionOptions: z + .object({ + type: z.string().optional(), + conditionGroupIndex: z.number().optional(), + }) + .optional() + .describe('Optional parent step connection options'), + nextStepId: z + .string() + .optional() + .describe('Optional ID of the step this new step should connect to'), + position: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional() + .describe('Optional position coordinates for the step'), + id: z.string().optional().describe('Optional step ID'), +}); + +export const createCreateWorkflowVersionStepTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'create_workflow_version_step' as const, + description: + 'Create a new step in a workflow version. This adds a step to the specified workflow version with the given configuration.', + inputSchema: createWorkflowVersionStepSchema, + execute: async (parameters: CreateWorkflowVersionStepInput) => { + try { + return await deps.workflowVersionStepService.createWorkflowVersionStep({ + workspaceId: context.workspaceId, + input: parameters, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create workflow version step: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/deactivate-workflow-version.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/deactivate-workflow-version.tool.ts new file mode 100644 index 0000000000..adb3bf09d4 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/deactivate-workflow-version.tool.ts @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +import { type WorkflowToolDependencies } from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const deactivateWorkflowVersionSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version to deactivate'), +}); + +type DeactivateWorkflowVersionInput = z.infer< + typeof deactivateWorkflowVersionSchema +>; + +export const createDeactivateWorkflowVersionTool = ( + deps: Pick, +) => ({ + name: 'deactivate_workflow_version' as const, + description: + 'Deactivate a workflow version. This makes the workflow version inactive and unavailable for execution.', + inputSchema: deactivateWorkflowVersionSchema, + execute: async (parameters: DeactivateWorkflowVersionInput) => { + try { + return await deps.workflowTriggerService.deactivateWorkflowVersion( + parameters.workflowVersionId, + ); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to deactivate workflow version: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts new file mode 100644 index 0000000000..1ff849c97b --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-edge.tool.ts @@ -0,0 +1,52 @@ +import { z } from 'zod'; + +import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const deleteWorkflowVersionEdgeSchema = z.object({ + workflowVersionId: z.string().describe('The ID of the workflow version'), + source: z.string().describe('The ID of the source step'), + target: z.string().describe('The ID of the target step'), + sourceConnectionOptions: z + .object({ + connectedStepType: z.literal(WorkflowActionType.ITERATOR), + settings: z.object({ + isConnectedToLoop: z.boolean(), + }), + }) + .optional() + .describe('Optional connection options for iterator steps'), +}); + +type DeleteWorkflowVersionEdgeInput = z.infer< + typeof deleteWorkflowVersionEdgeSchema +>; + +export const createDeleteWorkflowVersionEdgeTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'delete_workflow_version_edge' as const, + description: 'Delete a connection (edge) between workflow steps.', + inputSchema: deleteWorkflowVersionEdgeSchema, + execute: async (parameters: DeleteWorkflowVersionEdgeInput) => { + try { + return await deps.workflowVersionEdgeService.deleteWorkflowVersionEdge({ + source: parameters.source, + target: parameters.target, + workflowVersionId: parameters.workflowVersionId, + workspaceId: context.workspaceId, + sourceConnectionOptions: parameters.sourceConnectionOptions, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to delete workflow version edge: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool.ts new file mode 100644 index 0000000000..34cdf803b8 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/delete-workflow-version-step.tool.ts @@ -0,0 +1,42 @@ +import { z } from 'zod'; + +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const deleteWorkflowVersionStepSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version containing the step'), + stepId: z.string().describe('The ID of the step to delete'), +}); + +type DeleteWorkflowVersionStepInput = z.infer< + typeof deleteWorkflowVersionStepSchema +>; + +export const createDeleteWorkflowVersionStepTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'delete_workflow_version_step' as const, + description: + 'Delete a step from a workflow version. This removes the step and updates the workflow structure.', + inputSchema: deleteWorkflowVersionStepSchema, + execute: async (parameters: DeleteWorkflowVersionStepInput) => { + try { + return await deps.workflowVersionStepService.deleteWorkflowVersionStep({ + workspaceId: context.workspaceId, + workflowVersionId: parameters.workflowVersionId, + stepIdToDelete: parameters.stepId, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to delete workflow version step: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool.ts new file mode 100644 index 0000000000..6c08e268c2 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-positions.tool.ts @@ -0,0 +1,47 @@ +import { z } from 'zod'; + +import type { UpdateWorkflowVersionPositionsInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-positions-input.dto'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const updateWorkflowVersionPositionsSchema = z.object({ + workflowVersionId: z.string().describe('The ID of the workflow version'), + positions: z + .array( + z.object({ + id: z.string().describe('Step or trigger ID'), + position: z.object({ + x: z.number(), + y: z.number(), + }), + }), + ) + .describe('Array of step positions to update'), +}); + +export const createUpdateWorkflowVersionPositionsTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'update_workflow_version_positions' as const, + description: + 'Update the positions of multiple workflow steps. This is useful for reorganizing the workflow layout.', + inputSchema: updateWorkflowVersionPositionsSchema, + execute: async (parameters: UpdateWorkflowVersionPositionsInput) => { + try { + return await deps.workflowVersionService.updateWorkflowVersionPositions({ + workflowVersionId: parameters.workflowVersionId, + positions: parameters.positions, + workspaceId: context.workspaceId, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to update workflow version step positions: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts new file mode 100644 index 0000000000..3256e4fc52 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/update-workflow-version-step.tool.ts @@ -0,0 +1,45 @@ +import { + workflowActionSchema, + workflowTriggerSchema, +} from 'twenty-shared/workflow'; +import { z } from 'zod'; + +import type { UpdateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-step-input.dto'; +import { + type WorkflowToolContext, + type WorkflowToolDependencies, +} from 'src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type'; + +const updateWorkflowVersionStepSchema = z.object({ + workflowVersionId: z + .string() + .describe('The ID of the workflow version containing the step'), + step: z + .union([workflowTriggerSchema, workflowActionSchema]) + .describe('The updated step configuration'), +}); + +export const createUpdateWorkflowVersionStepTool = ( + deps: Pick, + context: WorkflowToolContext, +) => ({ + name: 'update_workflow_version_step' as const, + description: + 'Update an existing step in a workflow version. This modifies the step configuration.', + inputSchema: updateWorkflowVersionStepSchema, + execute: async (parameters: UpdateWorkflowVersionStepInput) => { + try { + return await deps.workflowVersionStepService.updateWorkflowVersionStep({ + workspaceId: context.workspaceId, + workflowVersionId: parameters.workflowVersionId, + step: parameters.step, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to update workflow version step: ${error.message}`, + }; + } + }, +}); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts new file mode 100644 index 0000000000..2dae74f14b --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/types/workflow-tool-dependencies.type.ts @@ -0,0 +1,21 @@ +import type { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; +import type { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import type { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; +import type { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.workspace-service'; +import type { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; +import type { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; +import type { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service'; + +export type WorkflowToolDependencies = { + workflowVersionStepService: WorkflowVersionStepWorkspaceService; + workflowVersionEdgeService: WorkflowVersionEdgeWorkspaceService; + workflowVersionService: WorkflowVersionWorkspaceService; + workflowTriggerService: WorkflowTriggerWorkspaceService; + workflowSchemaService: WorkflowSchemaWorkspaceService; + twentyORMGlobalManager: TwentyORMGlobalManager; + recordPositionService: RecordPositionService; +}; + +export type WorkflowToolContext = { + workspaceId: string; +}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts index 624af0089e..85ed37ab07 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; +import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module'; import { WorkflowVersionStepModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module'; @@ -17,6 +18,7 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace WorkflowTriggerModule, WorkflowSchemaModule, RecordPositionModule, + ToolGeneratorModule, ], providers: [WorkflowToolWorkspaceService], exports: [WorkflowToolWorkspaceService], 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 7906829c9a..1ab2588b8e 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,3 +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 { @@ -5,6 +7,54 @@ import { createAgentToolTestModule, } from './utils/agent-tool-test-utils'; +// Helper to create mock tools based on object name +const createMockTools = ( + objectName: string, + options: { + canRead?: boolean; + canCreate?: boolean; + canUpdate?: boolean; + canDelete?: boolean; + }, +): ToolSet => { + const tools: Record = {}; + + if (options.canRead) { + tools[`find_${objectName}`] = { + description: `Find ${objectName} records`, + execute: jest.fn(), + }; + tools[`find_one_${objectName}`] = { + description: `Find one ${objectName} record`, + execute: jest.fn(), + }; + } + if (options.canCreate) { + tools[`create_${objectName}`] = { + description: `Create ${objectName} record`, + execute: jest.fn(), + }; + } + if (options.canUpdate) { + tools[`update_${objectName}`] = { + description: `Update ${objectName} record`, + execute: jest.fn(), + }; + } + if (options.canDelete) { + tools[`soft_delete_${objectName}`] = { + description: `Delete ${objectName} record`, + execute: jest.fn(), + }; + tools[`soft_delete_many_${objectName}`] = { + description: `Delete many ${objectName} records`, + execute: jest.fn(), + }; + } + + return tools as unknown as ToolSet; +}; + describe('AgentToolGeneratorService Integration', () => { let context: AgentToolTestContext; @@ -18,6 +68,7 @@ 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, @@ -49,6 +100,19 @@ describe('AgentToolGeneratorService Integration', () => { ), ]); + // Configure perObjectToolGeneratorService to return the expected tools + jest + .spyOn(context.perObjectToolGeneratorService, 'generate') + .mockResolvedValue( + createMockTools('testObject', { + canRead: true, + canCreate: true, + canUpdate: true, + canDelete: true, + }), + ); + + // Act const tools = await context.agentToolService.generateToolsForAgent( context.testAgentId, context.testWorkspaceId, @@ -56,6 +120,7 @@ describe('AgentToolGeneratorService Integration', () => { [context.testRoleId], ); + // Assert expect(tools).toBeDefined(); expect(Object.keys(tools)).toHaveLength(6); expect(Object.keys(tools)).toContain('create_testObject'); @@ -67,6 +132,7 @@ 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]); @@ -93,6 +159,19 @@ describe('AgentToolGeneratorService Integration', () => { ), ]); + // Configure perObjectToolGeneratorService to return read-only tools + jest + .spyOn(context.perObjectToolGeneratorService, 'generate') + .mockResolvedValue( + createMockTools('testObject', { + canRead: true, + canCreate: false, + canUpdate: false, + canDelete: false, + }), + ); + + // Act const tools = await context.agentToolService.generateToolsForAgent( context.testAgentId, context.testWorkspaceId, @@ -100,6 +179,7 @@ describe('AgentToolGeneratorService Integration', () => { [context.testRoleId], ); + // Assert expect(tools).toBeDefined(); expect(Object.keys(tools)).toHaveLength(2); expect(Object.keys(tools)).toContain('find_testObject'); @@ -109,15 +189,18 @@ describe('AgentToolGeneratorService Integration', () => { }); it('should return no tool for agent without role', async () => { + // Act const tools = await context.agentToolService.generateToolsForAgent( context.testAgentId, context.testWorkspaceId, ); + // Assert expect(Object.keys(tools)).toHaveLength(0); }); it('should filter out workflow-run objects', async () => { + // Arrange const workflowObject = { ...context.testObjectMetadata, nameSingular: 'workflow', @@ -148,6 +231,20 @@ describe('AgentToolGeneratorService Integration', () => { fromObjectMetadataEntityToFlatObjectMetadata(workflowObject), ]); + // Note: workflow objects are filtered out by PerObjectToolGeneratorService, + // 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, + }), + ); + + // Act const tools = await context.agentToolService.generateToolsForAgent( context.testAgentId, context.testWorkspaceId, @@ -155,6 +252,7 @@ describe('AgentToolGeneratorService Integration', () => { [context.testRoleId], ); + // Assert expect(Object.keys(tools)).toHaveLength(6); }); }); 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 7272d9e1b7..d76641dcc9 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 @@ -9,13 +9,14 @@ import { DeleteRecordService } from 'src/engine/core-modules/record-crud/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 { 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 { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.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 { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; @@ -24,8 +25,8 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada 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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; 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'; @@ -37,6 +38,7 @@ export interface AgentToolTestContext { objectMetadataService: ObjectMetadataService; roleRepository: Repository; workspaceCacheService: WorkspaceCacheService; + perObjectToolGeneratorService: PerObjectToolGeneratorService; twentyORMGlobalManager: TwentyORMGlobalManager; testAgent: AgentEntity & { roleId: string | null }; testRole: RoleEntity; @@ -94,6 +96,12 @@ export const createAgentToolTestModule = getOrRecompute: jest.fn(), }, }, + { + provide: PerObjectToolGeneratorService, + useValue: { + generate: jest.fn().mockResolvedValue({}), + }, + }, { provide: ToolService, useClass: ToolService, @@ -314,6 +322,9 @@ export const createAgentToolTestModule = WorkspaceCacheService, ); + const perObjectToolGeneratorService = + module.get(PerObjectToolGeneratorService); + return { module, agentToolService, @@ -321,6 +332,7 @@ export const createAgentToolTestModule = objectMetadataService, roleRepository, workspaceCacheService, + perObjectToolGeneratorService, twentyORMGlobalManager, testAgent, testRole,