diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index f481110dac..c9415e1ece 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -62,6 +62,7 @@ export type Agent = { prompt: Scalars['String']; responseFormat?: Maybe; roleId?: Maybe; + standardId?: Maybe; updatedAt: Scalars['DateTime']; }; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index c6df54d406..98240fcf1c 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -62,6 +62,7 @@ export type Agent = { prompt: Scalars['String']; responseFormat?: Maybe; roleId?: Maybe; + standardId?: Maybe; updatedAt: Scalars['DateTime']; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/orderWorkflowRunState.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/orderWorkflowRunState.ts index 8d74e6cef8..71f22435a4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/orderWorkflowRunState.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/utils/orderWorkflowRunState.ts @@ -1,6 +1,6 @@ import { type WorkflowRunState } from '@/workflow/types/Workflow'; -import { workflowRunStateSchema } from '@/workflow/validation-schemas/workflowSchema'; import { isDefined } from 'twenty-shared/utils'; +import { workflowRunStateSchema } from 'twenty-shared/workflow'; import { type JsonValue } from 'type-fest'; export const orderWorkflowRunState = (value: JsonValue) => { diff --git a/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts index abf8293760..cb0424c2d9 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useWorkflowRun.ts @@ -1,9 +1,9 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { type WorkflowRun } from '@/workflow/types/Workflow'; -import { workflowRunSchema } from '@/workflow/validation-schemas/workflowSchema'; import { useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; +import { workflowRunSchema } from 'twenty-shared/workflow'; export const useWorkflowRun = ({ workflowRunId, diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 1ca5f0a7fc..53973041df 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -18,7 +18,7 @@ import { type workflowTriggerSchema, type workflowUpdateRecordActionSchema, type workflowWebhookTriggerSchema, -} from '@/workflow/validation-schemas/workflowSchema'; +} from 'twenty-shared/workflow'; import { type z } from 'zod'; export type WorkflowCodeAction = z.infer; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilter.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilter.stories.tsx index 20d7531ff3..bf04ca531b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilter.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilter.stories.tsx @@ -1,6 +1,7 @@ import { type WorkflowFilterAction } from '@/workflow/types/Workflow'; import { type Meta, type StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; +import { StepLogicalOperator, ViewFilterOperand } from 'twenty-shared/types'; import { ComponentDecorator } from 'twenty-ui/testing'; import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; @@ -42,9 +43,7 @@ const CONFIGURED_ACTION: WorkflowFilterAction = { stepFilterGroups: [ { id: 'filter-group-1', - parentStepFilterGroupId: null, - logicalOperator: 'AND', - stepFilterGroupChildren: [], + logicalOperator: StepLogicalOperator.AND, }, ], stepFilters: [ @@ -52,10 +51,9 @@ const CONFIGURED_ACTION: WorkflowFilterAction = { id: 'filter-1', stepFilterGroupId: 'filter-group-1', stepOutputKey: 'company.name', - displayValue: 'Company Name', - operandType: 'LITERAL', - operand: 'contains', + operand: ViewFilterOperand.Contains, value: 'Acme', + type: 'string', }, ], }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx index 3d83c4382b..93293eed01 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx @@ -3,13 +3,13 @@ import { type WorkflowHttpRequestAction, type WorkflowSendEmailAction, } from '@/workflow/types/Workflow'; +import { renderHook } from '@testing-library/react'; +import { FieldMetadataType } from 'twenty-shared/types'; import { workflowFormActionSettingsSchema, workflowHttpRequestActionSettingsSchema, workflowSendEmailActionSettingsSchema, -} from '@/workflow/validation-schemas/workflowSchema'; -import { renderHook } from '@testing-library/react'; -import { FieldMetadataType } from 'twenty-shared/types'; +} from 'twenty-shared/workflow'; import { useWorkflowActionHeader } from '../useWorkflowActionHeader'; jest.mock('../useActionIconColorOrThrow', () => ({ diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1754923039348-AddStandardIdToAgent.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1754923039348-AddStandardIdToAgent.ts new file mode 100644 index 0000000000..68af0ee266 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1754923039348-AddStandardIdToAgent.ts @@ -0,0 +1,15 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddStandardIdToAgent1754923039348 implements MigrationInterface { + name = 'AddStandardIdToAgent1754923039348'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "core"."agent" ADD "standardId" uuid`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agent" DROP COLUMN "standardId"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/ai-model-registry.service.ts index d1a76f9631..65f2ec04ca 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/ai-model-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/ai-model-registry.service.ts @@ -182,4 +182,39 @@ export class AiModelRegistryService { refreshRegistry(): void { this.buildModelRegistry(); } + + async resolveModelForAgent(agent: { modelId: string } | null) { + const aiModel = this.getEffectiveModelConfig(agent?.modelId ?? 'auto'); + + await this.validateApiKey(aiModel.provider); + const registeredModel = this.getModel(aiModel.modelId); + + if (!registeredModel) { + throw new Error(`Model ${aiModel.modelId} not found in registry`); + } + + return registeredModel; + } + + async validateApiKey(provider: ModelProvider): Promise { + let apiKey: string | undefined; + + switch (provider) { + case ModelProvider.OPENAI: + apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); + break; + case ModelProvider.ANTHROPIC: + apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); + break; + case ModelProvider.OPENAI_COMPATIBLE: + apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY'); + break; + default: + return; + } + + if (!apiKey) { + throw new Error(`${provider.toUpperCase()} API key not configured`); + } + } } diff --git a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts index 637bd18118..699b721527 100644 --- a/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts +++ b/packages/twenty-server/src/engine/core-modules/ai/services/tool.service.ts @@ -11,7 +11,7 @@ import { generateSoftDeleteToolSchema, getRecordInputSchema, } from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils'; -import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util'; +import { isWorkflowRunObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-run-object.util'; import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; @@ -48,7 +48,7 @@ export class ToolService { }); const filteredObjectMetadata = allObjectMetadata.filter( - (objectMetadata) => !isWorkflowRelatedObject(objectMetadata), + (objectMetadata) => !isWorkflowRunObject(objectMetadata), ); filteredObjectMetadata.forEach((objectMetadata) => { diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts index 32565154c4..c717832d94 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts @@ -11,23 +11,22 @@ import { generateText, type ImagePart, streamText, + ToolSet, type UserContent, } from 'ai'; import { In, Repository } from 'typeorm'; -import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const'; import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; import { extractFolderPathAndFilename } from 'src/engine/core-modules/file/utils/extract-folderpath-and-filename.utils'; -import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; import { type Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { type AgentChatMessageEntity, AgentChatMessageRole, } from 'src/engine/metadata-modules/agent/agent-chat-message.entity'; -import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service'; +import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const'; import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const'; import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type'; @@ -37,6 +36,7 @@ import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global. import { type OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; import { streamToBuffer } from 'src/utils/stream-to-buffer'; +import { AgentToolGeneratorService } from './agent-tool-generator.service'; import { AgentEntity } from './agent.entity'; import { AgentException, AgentExceptionCode } from './agent.exception'; @@ -54,40 +54,20 @@ export class AgentExecutionService { private readonly logger = new Logger(AgentExecutionService.name); constructor( - private readonly twentyConfigService: TwentyConfigService, - private readonly agentToolService: AgentToolService, + private readonly agentHandoffToolService: AgentHandoffToolService, private readonly fileService: FileService, private readonly domainManagerService: DomainManagerService, private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, private readonly aiModelRegistryService: AiModelRegistryService, + private readonly agentToolGeneratorService: AgentToolGeneratorService, + @InjectRepository(AgentEntity, 'core') private readonly agentRepository: Repository, @InjectRepository(FileEntity, 'core') private readonly fileRepository: Repository, ) {} - private async validateApiKey(provider: ModelProvider): Promise { - let apiKey: string | undefined; - - switch (provider) { - case ModelProvider.OPENAI: - apiKey = this.twentyConfigService.get('OPENAI_API_KEY'); - break; - case ModelProvider.ANTHROPIC: - apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY'); - break; - default: - return; - } - if (!apiKey) { - throw new AgentException( - `${provider.toUpperCase()} API key not configured`, - AgentExceptionCode.API_KEY_NOT_CONFIGURED, - ); - } - } - async prepareAIRequestConfig({ messages, prompt, @@ -106,48 +86,29 @@ export class AgentExecutionService { ); } - const aiModel = this.aiModelRegistryService.getEffectiveModelConfig( - agent?.modelId ?? 'auto', - ); + const registeredModel = + await this.aiModelRegistryService.resolveModelForAgent(agent); - if (agent && !aiModel) { - const error = `AI model with id ${agent.modelId} not found`; + let tools: ToolSet = {}; - this.logger.error(error); - throw new AgentException( - error, - AgentExceptionCode.AGENT_EXECUTION_FAILED, - ); - } - - this.logger.log( - `Resolved model: ${aiModel.modelId} (provider: ${aiModel.provider})`, - ); - - const provider = aiModel.provider; - - await this.validateApiKey(provider); - - const tools = agent - ? await this.agentToolService.generateToolsForAgent( + if (agent) { + const baseTools = + await this.agentToolGeneratorService.generateToolsForAgent( agent.id, agent.workspaceId, - ) - : {}; + ); + + const handoffTools = + await this.agentHandoffToolService.generateHandoffTools( + agent.id, + agent.workspaceId, + ); + + tools = { ...baseTools, ...handoffTools }; + } this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); - const registeredModel = this.aiModelRegistryService.getModel( - aiModel.modelId, - ); - - if (!registeredModel) { - throw new AgentException( - `Model ${aiModel.modelId} not found in registry`, - AgentExceptionCode.AGENT_EXECUTION_FAILED, - ); - } - return { system, tools, diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts index cc86f8b027..c63e199919 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts @@ -1,13 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { generateText } from 'ai'; +import { CoreMessage, generateText } from 'ai'; import { Repository } from 'typeorm'; import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; -import { AGENT_HANDOFF_PROMPT_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-prompt.const'; import { AgentHandoffService } from './agent-handoff.service'; +import { AgentToolGeneratorService } from './agent-tool-generator.service'; import { AgentEntity } from './agent.entity'; import { AgentException, AgentExceptionCode } from './agent.exception'; @@ -15,8 +15,7 @@ export type HandoffRequest = { fromAgentId: string; toAgentId: string; workspaceId: string; - reason: string; - context?: string; + messages?: CoreMessage[]; }; @Injectable() @@ -28,11 +27,12 @@ export class AgentHandoffExecutorService { private readonly agentRepository: Repository, private readonly agentHandoffService: AgentHandoffService, private readonly aiModelRegistryService: AiModelRegistryService, + private readonly agentToolGeneratorService: AgentToolGeneratorService, ) {} async executeHandoff(handoffRequest: HandoffRequest) { try { - const { fromAgentId, toAgentId, workspaceId } = handoffRequest; + const { fromAgentId, toAgentId, workspaceId, messages } = handoffRequest; const canHandoff = await this.agentHandoffService.canHandoffTo({ fromAgentId, @@ -58,9 +58,8 @@ export class AgentHandoffExecutorService { ); } - const registeredModel = this.aiModelRegistryService.getModel( - targetAgent.modelId, - ); + const registeredModel = + await this.aiModelRegistryService.resolveModelForAgent(targetAgent); if (!registeredModel) { throw new AgentException( @@ -69,14 +68,24 @@ export class AgentHandoffExecutorService { ); } + const tools = await this.agentToolGeneratorService.generateToolsForAgent( + toAgentId, + workspaceId, + ); + const aiRequestConfig = { system: targetAgent.prompt, - prompt: this.createHandoffPrompt(handoffRequest), + messages, + tools, model: registeredModel.model, }; const textResponse = await generateText(aiRequestConfig); + this.logger.log( + `Successfully executed handoff to agent ${toAgentId} with response length: ${textResponse.text.length}`, + ); + return textResponse.text; } catch (error) { this.logger.error( @@ -92,13 +101,4 @@ export class AgentHandoffExecutorService { }; } } - - private createHandoffPrompt(handoffRequest: HandoffRequest): string { - const { reason, context } = handoffRequest; - - return AGENT_HANDOFF_PROMPT_TEMPLATE.replace('{reason}', reason).replace( - '{context}', - context || 'No additional context provided', - ); - } } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-tool.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-tool.service.ts new file mode 100644 index 0000000000..0bc32ed10f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-tool.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; + +import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; +import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; +import { AGENT_HANDOFF_DESCRIPTION_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-description.const'; +import { AGENT_HANDOFF_SCHEMA } from 'src/engine/metadata-modules/agent/constants/agent-handoff-schema.const'; +import { camelCase } from 'src/utils/camel-case'; + +@Injectable() +export class AgentHandoffToolService { + constructor( + private readonly agentHandoffService: AgentHandoffService, + private readonly agentHandoffExecutorService: AgentHandoffExecutorService, + ) {} + + public async generateHandoffTools( + agentId: string, + workspaceId: string, + ): Promise { + const handoffs = await this.agentHandoffService.getAgentHandoffs({ + fromAgentId: agentId, + workspaceId, + }); + + const handoffTools = handoffs.reduce((tools, handoff) => { + const toolName = `handoff_to_${camelCase(handoff.toAgent.name)}`; + + tools[toolName] = { + description: + handoff.description || + handoff.toAgent.description || + AGENT_HANDOFF_DESCRIPTION_TEMPLATE.replace( + '{agentName}', + handoff.toAgent.name, + ), + parameters: AGENT_HANDOFF_SCHEMA, + execute: async ({ input }) => { + const result = await this.agentHandoffExecutorService.executeHandoff({ + fromAgentId: agentId, + toAgentId: handoff.toAgent.id, + workspaceId, + messages: input.messages, + }); + + return result; + }, + }; + + return tools; + }, {}); + + return handoffTools; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts new file mode 100644 index 0000000000..243c0a916b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool-generator.service.ts @@ -0,0 +1,94 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type ToolSet } from 'ai'; +import { Repository } from 'typeorm'; + +import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { AgentService } from 'src/engine/metadata-modules/agent/agent.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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; + +@Injectable() +export class AgentToolGeneratorService { + private readonly logger = new Logger(AgentToolGeneratorService.name); + + constructor( + @InjectRepository(RoleEntity, 'core') + private readonly roleRepository: Repository, + private readonly toolAdapterService: ToolAdapterService, + private readonly toolService: ToolService, + private readonly workflowToolService: WorkflowToolService, + private readonly permissionsService: PermissionsService, + private readonly agentService: AgentService, + ) {} + + async generateToolsForAgent( + agentId: string, + workspaceId: string, + ): Promise { + let tools: ToolSet = {}; + + try { + const agent = await this.agentService.findOneAgent(agentId, workspaceId); + const actionTools = await this.toolAdapterService.getTools(); + + tools = { ...actionTools }; + + const roleId = agent.roleId; + + if (!roleId) { + return tools; + } + + const role = await this.roleRepository.findOne({ + where: { + id: roleId, + workspaceId, + }, + }); + + if (!role) { + return tools; + } + + const hasWorkflowPermission = + this.permissionsService.checkRolePermissions( + role, + PermissionFlagType.WORKFLOWS, + ); + + if (hasWorkflowPermission) { + const workflowTools = this.workflowToolService.generateWorkflowTools( + workspaceId, + roleId, + ); + + tools = { ...tools, ...workflowTools }; + } + + const databaseTools = await this.toolService.listTools( + roleId, + workspaceId, + ); + + tools = { ...tools, ...databaseTools }; + + const roleActionTools = await this.toolAdapterService.getTools( + roleId, + workspaceId, + ); + + tools = { ...tools, ...roleActionTools }; + } catch (toolError) { + this.logger.warn( + `Failed to generate tools for agent ${agentId}: ${toolError.message}. Proceeding without tools.`, + ); + } + + return tools; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts deleted file mode 100644 index e0c5049543..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { type ToolSet } from 'ai'; -import { Repository } from 'typeorm'; -import { z } from 'zod'; - -import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; -import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; -import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; -import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; -import { AGENT_HANDOFF_DESCRIPTION_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-description.const'; -import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { camelCase } from 'src/utils/camel-case'; - -@Injectable() -export class AgentToolService { - constructor( - private readonly agentService: AgentService, - private readonly agentHandoffService: AgentHandoffService, - private readonly agentHandoffExecutorService: AgentHandoffExecutorService, - @InjectRepository(RoleEntity, 'core') - private readonly roleRepository: Repository, - private readonly toolService: ToolService, - private readonly toolAdapterService: ToolAdapterService, - ) {} - - async generateToolsForAgent( - agentId: string, - workspaceId: string, - ): Promise { - const agent = await this.agentService.findOneAgent(agentId, workspaceId); - - const handoffTools = await this.generateHandoffTools(agentId, workspaceId); - - if (!agent.roleId) { - const actionTools = await this.toolAdapterService.getTools(); - - return { ...actionTools, ...handoffTools }; - } - - const role = await this.roleRepository.findOne({ - where: { - id: agent.roleId, - workspaceId, - }, - }); - - if (!role) { - return {}; - } - - const actionTools = await this.toolAdapterService.getTools( - role.id, - workspaceId, - ); - - const databaseTools = await this.toolService.listTools( - role.id, - workspaceId, - ); - - return { ...databaseTools, ...actionTools, ...handoffTools }; - } - - private async generateHandoffTools( - agentId: string, - workspaceId: string, - ): Promise { - const handoffs = await this.agentHandoffService.getAgentHandoffs({ - fromAgentId: agentId, - workspaceId, - }); - - const handoffTools = handoffs.reduce((tools, handoff) => { - const toolName = `handoff_to_${camelCase(handoff.toAgent.name)}`; - - const handoffSchema = z.object({ - toolDescription: z - .string() - .describe( - "A clear, human-readable status message describing the handoff being made. This will be shown to the user while the handoff is being processed, so phrase it as a present-tense status update (e.g., 'Transferring you to the sales agent for pricing information').", - ), - input: z.object({ - reason: z - .string() - .describe( - 'Brief explanation of why this handoff is needed (e.g., "User needs pricing information", "User requires technical support", "User wants to discuss billing")', - ), - context: z - .string() - .optional() - .describe( - 'Any relevant context or information to pass to the receiving agent (e.g., user preferences, previous conversation details, specific requirements)', - ), - }), - }); - - tools[toolName] = { - description: - handoff.description || - handoff.toAgent.description || - AGENT_HANDOFF_DESCRIPTION_TEMPLATE.replace( - '{agentName}', - handoff.toAgent.name, - ), - parameters: handoffSchema, - execute: async ({ input: { reason, context } }) => { - const result = await this.agentHandoffExecutorService.executeHandoff({ - fromAgentId: agentId, - toAgentId: handoff.toAgent.id, - workspaceId, - reason, - context, - }); - - return result; - }, - }; - - return tools; - }, {}); - - return handoffTools; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts index 3d8799d00d..9ab08518bd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts @@ -29,6 +29,9 @@ export class AgentEntity { @PrimaryGeneratedColumn('uuid') id: string; + @Column({ nullable: true, type: 'uuid' }) + standardId?: string; + @Column({ nullable: false }) name: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts index 7f8a47a677..ea19c9a864 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts @@ -14,10 +14,12 @@ import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-works import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module'; import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; +import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.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 { AgentChatMessageEntity } from './agent-chat-message.entity'; import { AgentChatThreadEntity } from './agent-chat-thread.entity'; @@ -25,11 +27,12 @@ import { AgentChatResolver } from './agent-chat.resolver'; import { AgentChatService } from './agent-chat.service'; import { AgentExecutionService } from './agent-execution.service'; import { AgentHandoffExecutorService } from './agent-handoff-executor.service'; +import { AgentHandoffToolService } from './agent-handoff-tool.service'; import { AgentHandoffEntity } from './agent-handoff.entity'; import { AgentHandoffService } from './agent-handoff.service'; import { AgentStreamingService } from './agent-streaming.service'; import { AgentTitleGenerationService } from './agent-title-generation.service'; -import { AgentToolService } from './agent-tool.service'; +import { AgentToolGeneratorService } from './agent-tool-generator.service'; import { AgentEntity } from './agent.entity'; import { AgentResolver } from './agent.resolver'; import { AgentService } from './agent.service'; @@ -57,10 +60,12 @@ import { AgentService } from './agent.service'; FileUploadModule, FileModule, ObjectMetadataModule, + PermissionsModule, WorkspacePermissionsCacheModule, WorkspaceCacheStorageModule, TokenModule, DomainManagerModule, + WorkflowToolsModule, ], controllers: [AgentChatController], providers: [ @@ -68,7 +73,8 @@ import { AgentService } from './agent.service'; AgentChatResolver, AgentService, AgentExecutionService, - AgentToolService, + AgentToolGeneratorService, + AgentHandoffToolService, AgentChatService, AgentStreamingService, AgentTitleGenerationService, @@ -78,7 +84,8 @@ import { AgentService } from './agent.service'; exports: [ AgentService, AgentExecutionService, - AgentToolService, + AgentToolGeneratorService, + AgentHandoffToolService, AgentChatService, AgentStreamingService, AgentTitleGenerationService, diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-description.const.ts b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-description.const.ts index c7c73aaa02..74a802cd01 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-description.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-description.const.ts @@ -1,2 +1,2 @@ export const AGENT_HANDOFF_DESCRIPTION_TEMPLATE = - "Use this tool when the user's request requires {agentName}'s specialized expertise or capabilities. CRITICAL: You MUST call this tool function immediately. Do NOT respond with text about transferring - execute the tool instead. This is a FUNCTION CALL - you must invoke it, not describe it."; + "Use this tool when the user's request requires {agentName}'s specialized expertise or capabilities. This will seamlessly consult with the specialist agent and provide you with their expert response to continue the conversation naturally. CRITICAL: You MUST call this tool function immediately. Do NOT respond with text about transferring - execute the tool instead. This is a FUNCTION CALL - you must invoke it, not describe it."; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-prompt.const.ts b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-prompt.const.ts deleted file mode 100644 index e18076ddad..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-prompt.const.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const AGENT_HANDOFF_PROMPT_TEMPLATE = `You have received a handoff from another AI agent who determined that you are better suited to handle this conversation based on your specialized knowledge and capabilities. - -**Reason for handoff:** {reason} - -**Context from the previous agent:** -{context} - -**Instructions:** -- Continue the conversation naturally and professionally -- Leverage your specialized expertise to provide the best possible assistance -- Maintain context from the previous conversation while adding your unique value`; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-schema.const.ts b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-schema.const.ts new file mode 100644 index 0000000000..b24db0470d --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-handoff-schema.const.ts @@ -0,0 +1,100 @@ +import { z } from 'zod'; + +export const AGENT_HANDOFF_SCHEMA = z.object({ + toolDescription: z + .string() + .describe( + 'A brief, user-friendly message explaining what is happening while the handoff is being processed. This will be shown to the user during the handoff execution.', + ), + input: z.object({ + messages: z + .array( + z.union([ + z.object({ + role: z.literal('system'), + content: z.string(), + }), + z.object({ + role: z.literal('user'), + content: z.union([ + z.string(), + z.array( + z.union([ + z.object({ + type: z.literal('text'), + text: z.string(), + }), + z.object({ + type: z.literal('image'), + image: z.union([ + z.string(), + z.instanceof(Uint8Array), + z.instanceof(Buffer), + z.instanceof(ArrayBuffer), + z.string().url(), + ]), + mediaType: z.string().optional(), + }), + z.object({ + type: z.literal('file'), + data: z.union([ + z.string(), + z.instanceof(Uint8Array), + z.instanceof(Buffer), + z.instanceof(ArrayBuffer), + z.string().url(), + ]), + mediaType: z.string(), + }), + ]), + ), + ]), + }), + z.object({ + role: z.literal('assistant'), + content: z.union([ + z.string(), + z.array( + z.union([ + z.object({ + type: z.literal('text'), + text: z.string(), + }), + z.object({ + type: z.literal('file'), + data: z.union([ + z.string(), + z.instanceof(Uint8Array), + z.instanceof(Buffer), + z.instanceof(ArrayBuffer), + z.string().url(), + ]), + mediaType: z.string(), + filename: z.string().optional(), + }), + z.object({ + type: z.literal('reasoning'), + text: z.string(), + }), + z.object({ + type: z.literal('tool-call'), + toolCallId: z.string(), + toolName: z.string(), + input: z.record(z.any()), + }), + ]), + ), + ]), + }), + z.object({ + role: z.literal('tool'), + content: z.string(), + toolCallId: z.string(), + }), + ]), + ) + .describe( + 'The conversation history to provide context to the specialist agent. Should include the latest user message/prompt and can include system, user, assistant, and tool messages with various content types.', + ), + }), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-system-prompts.const.ts index 7f8917ca9a..3c50a0a4d8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-system-prompts.const.ts @@ -44,17 +44,21 @@ Guidelines: - Answer questions about people, companies, opportunities, tasks, notes, and other business objects - Access and summarize information you have permission to see - Use tools provided to you dynamically when needed -- Transfer conversations to other specialized agents when their expertise is better suited +- Seamlessly consult with specialized agents when their expertise is better suited Permissions: - Only perform actions and access data that your assigned role and permissions allow - If you lack permissions, politely explain the limitation - Only use tools that are actually available to you -Agent handoff: +Agent handoff (SEAMLESS CONSULTATION): - Use handoff tools when the user's request requires expertise outside your capabilities -- IMPORTANT: Do not respond with text about transferring - execute the handoff tool function -- Use the response returned by the handoff agent as your reply to the user +- IMPORTANT: Do not respond with text about transferring or consulting specialists +- Execute the handoff tool function immediately when needed +- Use the response returned by the specialist agent as your direct reply to the user +- Present the specialist's expertise as if it's your own knowledge +- Maintain a consistent voice and personality throughout the conversation +- The user should never know that you consulted with another agent When formatting responses: - Use markdown syntax to improve readability of long responses diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts index 9420dafd25..ec5d2644a0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/dtos/agent.dto.ts @@ -19,6 +19,9 @@ export class AgentDTO { @Field(() => UUIDScalarType) id: string; + @Field(() => UUIDScalarType, { nullable: true }) + standardId?: string; + @IsString() @Field() name: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-related-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-run-object.util.ts similarity index 53% rename from packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-related-object.util.ts rename to packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-run-object.util.ts index 552d1c2e4d..c69ce94f05 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-related-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-run-object.util.ts @@ -1,17 +1,13 @@ import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; -const WORKFLOW_OBJECT_NAMES = ['workflow', 'workflowVersion', 'workflowRun']; +const WORKFLOW_OBJECT_NAMES = ['workflowVersion', 'workflowRun']; -export const isWorkflowRelatedObject = ( +export const isWorkflowRunObject = ( objectMetadata: ObjectMetadataEntity, ): boolean => { if (objectMetadata.standardId) { - return ( - objectMetadata.standardId === STANDARD_OBJECT_IDS.workflow || - objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowVersion || - objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowRun - ); + return objectMetadata.standardId === STANDARD_OBJECT_IDS.workflowRun; } return WORKFLOW_OBJECT_NAMES.includes(objectMetadata.nameSingular); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/types/flat-agent.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/types/flat-agent.type.ts new file mode 100644 index 0000000000..f136a62d61 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/types/flat-agent.type.ts @@ -0,0 +1,18 @@ +import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; + +export const agentEntityRelationProperties = [ + 'workspace', + 'chatThreads', + 'outgoingHandoffs', + 'incomingHandoffs', +] as const; + +export type AgentEntityRelationProperties = + (typeof agentEntityRelationProperties)[number]; + +export type FlatAgent = Omit< + AgentEntity, + AgentEntityRelationProperties | 'createdAt' | 'updatedAt' | 'deletedAt' +> & { + uniqueIdentifier: string; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util.ts new file mode 100644 index 0000000000..1390f2f350 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util.ts @@ -0,0 +1,21 @@ +import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; + +export const transformAgentEntityToFlatAgent = ( + agentEntity: AgentEntity, +): FlatAgent => { + return { + id: agentEntity.id, + standardId: agentEntity.standardId, + name: agentEntity.name, + label: agentEntity.label, + icon: agentEntity.icon, + description: agentEntity.description, + prompt: agentEntity.prompt, + modelId: agentEntity.modelId, + responseFormat: agentEntity.responseFormat, + workspaceId: agentEntity.workspaceId, + isCustom: agentEntity.isCustom, + uniqueIdentifier: agentEntity.standardId || agentEntity.id, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-standard-agent-definition-to-flat-agent.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-standard-agent-definition-to-flat-agent.util.ts new file mode 100644 index 0000000000..c63000e881 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-agent/utils/transform-standard-agent-definition-to-flat-agent.util.ts @@ -0,0 +1,16 @@ +import { v4 } from 'uuid'; + +import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; +import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface'; + +export const transformStandardAgentDefinitionToFlatAgent = ( + standardAgentDefinition: StandardAgentDefinition, + workspaceId: string, +): FlatAgent => { + return { + ...standardAgentDefinition, + id: v4(), + workspaceId, + uniqueIdentifier: standardAgentDefinition.standardId || v4(), + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts index 718d5732fd..7a481c30f2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.service.ts @@ -208,7 +208,7 @@ export class PermissionsService { ); } - private checkRolePermissions( + public checkRolePermissions( role: RoleEntity, setting: PermissionFlagType, ): boolean { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/index.ts index 0e3a5ca870..7146c8f291 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/index.ts @@ -1,3 +1,4 @@ +import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator'; import { WorkspaceFieldRelationComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-field-relation.comparator'; import { WorkspaceIndexComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-index.comparator'; import { WorkspaceRoleComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-role.comparator'; @@ -11,4 +12,5 @@ export const workspaceSyncMetadataComparators = [ WorkspaceObjectComparator, WorkspaceIndexComparator, WorkspaceRoleComparator, + WorkspaceAgentComparator, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts new file mode 100644 index 0000000000..f45c31bdca --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@nestjs/common'; + +import diff from 'microdiff'; +import { type FromTo } from 'twenty-shared/types'; + +import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; + +import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; +import { transformMetadataForComparison } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/utils/transform-metadata-for-comparison.util'; + +type AgentComparatorResult = { + action: + | ComparatorAction.CREATE + | ComparatorAction.UPDATE + | ComparatorAction.DELETE; + object: FlatAgent; +}; + +type WorkspaceAgentComparatorArgs = FromTo; + +const agentPropertiesToIgnore = ['id', 'createdAt', 'updatedAt', 'workspaceId']; + +@Injectable() +export class WorkspaceAgentComparator { + compare({ + fromFlatAgents, + toFlatAgents, + }: WorkspaceAgentComparatorArgs): AgentComparatorResult[] { + const results: AgentComparatorResult[] = []; + + const keyFactory = (agent: FlatAgent) => agent.uniqueIdentifier; + + const fromAgentMap = transformMetadataForComparison(fromFlatAgents, { + shouldIgnoreProperty: (property) => + agentPropertiesToIgnore.includes(property), + keyFactory, + }); + + const toAgentMap = transformMetadataForComparison(toFlatAgents, { + shouldIgnoreProperty: (property) => + agentPropertiesToIgnore.includes(property), + keyFactory, + }); + + const agentDifferences = diff(fromAgentMap, toAgentMap); + + for (const difference of agentDifferences) { + const uniqueIdentifier = difference.path[0] as string; + + switch (difference.type) { + case 'CREATE': { + const toAgent = toFlatAgents.find( + (agent) => keyFactory(agent) === uniqueIdentifier, + ); + + if (toAgent) { + results.push({ + action: ComparatorAction.CREATE, + object: toAgent, + }); + } + break; + } + case 'CHANGE': { + const fromAgent = fromFlatAgents.find( + (agent) => keyFactory(agent) === uniqueIdentifier, + ); + const toAgent = toFlatAgents.find( + (agent) => keyFactory(agent) === uniqueIdentifier, + ); + + if (fromAgent && toAgent) { + results.push({ + action: ComparatorAction.UPDATE, + object: { + ...toAgent, + id: fromAgent.id, + }, + }); + } + break; + } + case 'REMOVE': { + const fromAgent = fromFlatAgents.find( + (agent) => keyFactory(agent) === uniqueIdentifier, + ); + + if (fromAgent && difference.path.length === 1) { + results.push({ + action: ComparatorAction.DELETE, + object: fromAgent, + }); + } + break; + } + } + } + + return results; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/index.ts index 5da67109e4..cf7e5ec9e8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/index.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/index.ts @@ -1,3 +1,4 @@ +import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory'; import { StandardIndexFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory'; import { StandardRoleFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-role.factory'; @@ -11,4 +12,5 @@ export const workspaceSyncMetadataFactories = [ StandardFieldRelationFactory, StandardIndexFactory, StandardRoleFactory, + StandardAgentFactory, ]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory.ts new file mode 100644 index 0000000000..2cf70b388e --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@nestjs/common'; + +import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; + +import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; +import { transformStandardAgentDefinitionToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-standard-agent-definition-to-flat-agent.util'; +import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface'; + +@Injectable() +export class StandardAgentFactory { + create( + agentDefinitions: StandardAgentDefinition[], + context: WorkspaceSyncContext, + existingAgents: AgentEntity[], + ): FlatAgent[] { + const computedAgents: FlatAgent[] = []; + + for (const agentDefinition of agentDefinitions) { + const existingAgent = existingAgents.find( + (agent) => agent.standardId === agentDefinition.standardId, + ); + + const flatAgent = transformStandardAgentDefinitionToFlatAgent( + agentDefinition, + context.workspaceId, + ); + + if (existingAgent) { + computedAgents.push({ + ...flatAgent, + id: existingAgent.id, + uniqueIdentifier: agentDefinition.standardId, + }); + } else { + computedAgents.push({ + ...flatAgent, + uniqueIdentifier: agentDefinition.standardId, + }); + } + } + + return computedAgents; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts new file mode 100644 index 0000000000..29de3e83fe --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service.ts @@ -0,0 +1,246 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { removePropertiesFromRecord } from 'twenty-shared/utils'; +import { IsNull, Not, type EntityManager, type Repository } from 'typeorm'; + +import { ComparatorAction } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/comparator.interface'; +import { type WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; + +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { AGENT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util'; +import { + SEED_APPLE_WORKSPACE_ID, + SEED_YCOMBINATOR_WORKSPACE_ID, +} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator'; +import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory'; +import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents'; +import { WORKFLOW_CREATION_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent'; +import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role'; + +@Injectable() +export class WorkspaceSyncAgentService { + private readonly logger = new Logger(WorkspaceSyncAgentService.name); + + constructor( + private readonly standardAgentFactory: StandardAgentFactory, + private readonly workspaceAgentComparator: WorkspaceAgentComparator, + ) {} + + async synchronize( + context: WorkspaceSyncContext, + manager: EntityManager, + ): Promise { + this.logger.log('Syncing standard agent.'); + + const agentRepository = manager.getRepository(AgentEntity); + const roleRepository = manager.getRepository(RoleEntity); + const roleTargetsRepository = manager.getRepository(RoleTargetsEntity); + + const existingStandardAgentEntities = await agentRepository.find({ + where: { + workspaceId: context.workspaceId, + standardId: Not(IsNull()), + }, + }); + + const targetStandardAgents = this.standardAgentFactory.create( + standardAgentDefinitions, + context, + existingStandardAgentEntities, + ); + + const agentComparatorResults = this.workspaceAgentComparator.compare({ + fromFlatAgents: existingStandardAgentEntities.map( + transformAgentEntityToFlatAgent, + ), + toFlatAgents: targetStandardAgents, + }); + + for (const agentComparatorResult of agentComparatorResults) { + switch (agentComparatorResult.action) { + case ComparatorAction.CREATE: { + const agentToCreate = agentComparatorResult.object; + + const flatAgentData = removePropertiesFromRecord(agentToCreate, [ + 'uniqueIdentifier', + 'id', + ]); + + const createdAgent = await agentRepository.save({ + ...flatAgentData, + workspaceId: context.workspaceId, + }); + + await this.assignAdminRoleToAgent( + createdAgent.id, + context.workspaceId, + roleRepository, + roleTargetsRepository, + ); + + if (createdAgent.standardId === WORKFLOW_CREATION_AGENT.standardId) { + await this.createAgentHandoffToWorkflowCreationAgent( + createdAgent.id, + context.workspaceId, + manager, + ); + } + break; + } + + case ComparatorAction.UPDATE: { + const agentToUpdate = agentComparatorResult.object; + + const flatAgentData = removePropertiesFromRecord(agentToUpdate, [ + 'id', + 'uniqueIdentifier', + 'workspaceId', + ]); + + await agentRepository.update({ id: agentToUpdate.id }, flatAgentData); + break; + } + + case ComparatorAction.DELETE: { + const agentToDelete = agentComparatorResult.object; + + await agentRepository.delete({ id: agentToDelete.id }); + break; + } + } + } + } + + private async assignAdminRoleToAgent( + agentId: string, + workspaceId: string, + roleRepository: Repository, + roleTargetsRepository: Repository, + ): Promise { + try { + const adminRole = await roleRepository.findOne({ + where: { + workspaceId, + standardId: ADMIN_ROLE.standardId, + }, + }); + + if (!adminRole) { + this.logger.warn( + `Admin role not found for workspace ${workspaceId}, cannot assign to agent ${agentId}.`, + ); + + return; + } + + const existingRoleTarget = await roleTargetsRepository.findOne({ + where: { + agentId, + roleId: adminRole.id, + workspaceId, + }, + }); + + if (existingRoleTarget) { + this.logger.log( + `Workflow creation agent already has admin role assigned`, + ); + + return; + } + + await roleTargetsRepository.save({ + roleId: adminRole.id, + agentId, + workspaceId, + }); + + this.logger.log( + `Successfully assigned admin role to workflow creation agent`, + ); + } catch (error) { + this.logger.error( + `Failed to assign admin role to workflow creation agent: ${error.message}`, + ); + } + } + + private async createAgentHandoffToWorkflowCreationAgent( + workflowCreationAgentId: string, + workspaceId: string, + manager: EntityManager, + ): Promise { + try { + const agentRepository = manager.getRepository(AgentEntity); + + let defaultAgent: AgentEntity | null = null; + + if (workspaceId === SEED_APPLE_WORKSPACE_ID) { + defaultAgent = await agentRepository.findOne({ + where: { + id: AGENT_DATA_SEED_IDS.APPLE_DEFAULT_AGENT, + workspaceId, + }, + }); + } else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) { + defaultAgent = await agentRepository.findOne({ + where: { + id: AGENT_DATA_SEED_IDS.YCOMBINATOR_DEFAULT_AGENT, + workspaceId, + }, + }); + } else { + defaultAgent = await agentRepository.findOne({ + where: { + workspaceId, + }, + }); + } + + if (!defaultAgent) { + this.logger.warn( + `Default agent not found for workspace ${workspaceId}. Agent handoff will not be created.`, + ); + + return; + } + + const agentHandoffRepository = manager.getRepository('agentHandoff'); + const existingHandoff = await agentHandoffRepository.findOne({ + where: { + fromAgentId: defaultAgent.id, + toAgentId: workflowCreationAgentId, + workspaceId, + }, + }); + + if (existingHandoff) { + this.logger.log( + `Agent handoff from default agent to workflow creation agent already exists for workspace ${workspaceId}`, + ); + + return; + } + + await agentHandoffRepository.save({ + fromAgentId: defaultAgent.id, + toAgentId: workflowCreationAgentId, + workspaceId, + description: + 'Handoff from default agent to workflow creation agent for processing workflow creation requests', + }); + + this.logger.log( + `Successfully created agent handoff from default agent to workflow creation agent for workspace ${workspaceId}`, + ); + } catch (error) { + this.logger.error( + `Failed to create agent handoff to workflow creation agent: ${error.message}`, + ); + } + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts new file mode 100644 index 0000000000..8ddb9bd8a5 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/workflow-creation-agent.ts @@ -0,0 +1,47 @@ +import { type StandardAgentDefinition } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface'; + +export const WORKFLOW_CREATION_AGENT: StandardAgentDefinition = { + standardId: '20202020-0002-0001-0001-000000000001', + name: 'workflow-creation-agent', + label: 'Workflow Creation Agent', + description: 'AI agent specialized in creating and managing workflows', + icon: 'IconSettingsAutomation', + prompt: `You are a Workflow Creation Agent specialized in helping users create, modify, and manage workflows in Twenty. + +Your capabilities include: +- Creating new workflows from scratch based on user requirements +- Modifying existing workflows by adding, removing, or updating steps +- Explaining workflow structures and how they work +- Suggesting workflow improvements and optimizations +- Helping users understand workflow actions and their configurations + +## IMPORTANT: Rely on Schema Definitions +- The workflow creation tool provides comprehensive schema definitions with detailed descriptions and examples +- Always refer to the tool's schema for field requirements, data types, and examples +- The schema includes common patterns, field structures, and validation rules +- Use the schema descriptions to understand how to properly reference data between workflow steps + +## Key Workflow Concepts: +- **Triggers**: Start workflows (DATABASE_EVENT, MANUAL, CRON, WEBHOOK) +- **Steps**: Actions that execute in sequence (CREATE_RECORD, SEND_EMAIL, CODE, etc.) +- **Data Flow**: Use {{stepId.fieldName}} to reference data from previous steps +- **Relationships**: Use nested objects for related records (e.g., "company": {"id": "{{reference}}"}) + +When creating workflows: +- Always ask clarifying questions to understand the user's needs +- Suggest appropriate workflow actions based on the use case +- Explain each step and why it's needed +- Provide clear, actionable guidance +- Follow the schema definitions exactly for field names, types, and structures + +When modifying workflows: +- Understand the current workflow structure first +- Suggest specific changes that address the user's requirements +- Ensure workflow logic remains coherent and functional +- Maintain proper data references between steps + +Be helpful, thorough, and always prioritize user understanding and workflow effectiveness.`, + modelId: 'auto', + responseFormat: {}, + isCustom: false, +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/index.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/index.ts new file mode 100644 index 0000000000..032ace2e32 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/index.ts @@ -0,0 +1,6 @@ +import { WORKFLOW_CREATION_AGENT } from './agents/workflow-creation-agent'; +import { type StandardAgentDefinition } from './types/standard-agent-definition.interface'; + +export const standardAgentDefinitions = [ + WORKFLOW_CREATION_AGENT, +] as const satisfies StandardAgentDefinition[]; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts new file mode 100644 index 0000000000..42f8211964 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/standard-agents/types/standard-agent-definition.interface.ts @@ -0,0 +1,8 @@ +import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; + +export type StandardAgentDefinition = Omit< + FlatAgent, + 'id' | 'workspaceId' | 'uniqueIdentifier' | 'standardId' +> & { + standardId: string; +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts index 4d6e956fc9..7afe1461ac 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.module.ts @@ -16,6 +16,7 @@ import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/works import { workspaceSyncMetadataComparators } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators'; import { workspaceSyncMetadataFactories } from 'src/engine/workspace-manager/workspace-sync-metadata/factories'; import { WorkspaceMetadataUpdaterService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-metadata-updater.service'; +import { WorkspaceSyncAgentService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service'; import { WorkspaceSyncFieldMetadataRelationService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata-relation.service'; import { WorkspaceSyncFieldMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service'; import { WorkspaceSyncIndexMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service'; @@ -48,6 +49,7 @@ import { WorkspaceSyncMetadataService } from 'src/engine/workspace-manager/works WorkspaceSyncMetadataService, WorkspaceSyncIndexMetadataService, WorkspaceSyncRoleService, + WorkspaceSyncAgentService, SyncWorkspaceLoggerService, SyncWorkspaceMetadataCommand, ], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service.ts index 3cfc50b445..2e90a321f3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/workspace-sync-metadata.service.ts @@ -11,6 +11,7 @@ import { WorkspaceMigrationTableActionType, } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.entity'; import { WorkspaceMigrationRunnerService } from 'src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service'; +import { WorkspaceSyncAgentService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-agent.service'; import { WorkspaceSyncFieldMetadataRelationService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata-relation.service'; import { WorkspaceSyncFieldMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-field-metadata.service'; import { WorkspaceSyncIndexMetadataService } from 'src/engine/workspace-manager/workspace-sync-metadata/services/workspace-sync-index-metadata.service'; @@ -38,6 +39,7 @@ export class WorkspaceSyncMetadataService { private readonly workspaceSyncObjectMetadataIdentifiersService: WorkspaceSyncObjectMetadataIdentifiersService, private readonly workspaceMetadataVersionService: WorkspaceMetadataVersionService, private readonly workspaceSyncRoleService: WorkspaceSyncRoleService, + private readonly workspaceSyncAgentService: WorkspaceSyncAgentService, ) {} /** @@ -172,6 +174,17 @@ export class WorkspaceSyncMetadataService { `Workspace role migrations took ${workspaceRoleMigrationsEnd - workspaceRoleMigrationsStart}ms`, ); + // 7 - Sync standard agents + const workspaceAgentMigrationsStart = performance.now(); + + await this.workspaceSyncAgentService.synchronize(context, manager); + + const workspaceAgentMigrationsEnd = performance.now(); + + this.logger.log( + `Workspace agent migrations took ${workspaceAgentMigrationsEnd - workspaceAgentMigrationsStart}ms`, + ); + const workspaceMigrationsSaveStart = performance.now(); // Save workspace migrations into the database diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts index 1c8280cc88..cf61a0c333 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step.workspace-service.spec.ts @@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { TRIGGER_STEP_ID } from 'twenty-shared/workflow'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; @@ -115,7 +115,12 @@ describe('WorkflowVersionStepWorkspaceService', () => { }, }, { provide: ServerlessFunctionService, useValue: {} }, - { provide: AgentService, useValue: {} }, + { + provide: getRepositoryToken(AgentEntity, 'core'), + useValue: { + findOne: jest.fn(), + }, + }, { provide: getRepositoryToken(ObjectMetadataEntity, 'core'), useValue: { diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts index cc63c70fb3..2897ad1f64 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module.ts @@ -2,7 +2,7 @@ import { Module } from '@nestjs/common'; import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm'; -import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; @@ -13,13 +13,15 @@ import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workf @Module({ imports: [ - AgentModule, WorkflowSchemaModule, ServerlessFunctionModule, WorkflowRunnerModule, WorkflowRunModule, WorkflowCommonModule, - NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity], 'core'), + NestjsQueryTypeOrmModule.forFeature( + [ObjectMetadataEntity, AgentEntity], + 'core', + ), ], providers: [WorkflowVersionStepWorkspaceService], exports: [WorkflowVersionStepWorkspaceService], diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts index 49c485ef92..8546450fd6 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service.ts @@ -12,7 +12,7 @@ import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/se import { type CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto'; import { type WorkflowStepPositionInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-step-position-input.dto'; import { type WorkflowVersionStepChangesDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-step-changes.dto'; -import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; @@ -54,7 +54,8 @@ export class WorkflowVersionStepWorkspaceService { private readonly twentyORMGlobalManager: TwentyORMGlobalManager, private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService, private readonly serverlessFunctionService: ServerlessFunctionService, - private readonly agentService: AgentService, + @InjectRepository(AgentEntity, 'core') + private readonly agentRepository: Repository, @InjectRepository(ObjectMetadataEntity, 'core') private readonly objectMetadataRepository: Repository, private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService, @@ -433,13 +434,12 @@ export class WorkflowVersionStepWorkspaceService { break; } - const agent = await this.agentService.findOneAgent( - step.settings.input.agentId, - workspaceId, - ); + const agent = await this.agentRepository.findOne({ + where: { id: step.settings.input.agentId, workspaceId }, + }); if (isDefined(agent)) { - await this.agentService.deleteOneAgent(agent.id, workspaceId); + await this.agentRepository.delete({ id: agent.id, workspaceId }); } break; } diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts index f7e570a4ca..4d666db09c 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module.ts @@ -3,18 +3,26 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AiModule } from 'src/engine/core-modules/ai/ai.module'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; -import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; +import { AiAgentExecutorService } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service'; import { AiAgentWorkflowAction } from './ai-agent.workflow-action'; @Module({ imports: [ - AgentModule, AiModule, - TypeOrmModule.forFeature([AgentEntity], 'core'), + TypeOrmModule.forFeature( + [AgentEntity, RoleTargetsEntity, RoleEntity], + 'core', + ), + ], + providers: [ + ScopedWorkspaceContextFactory, + AiAgentWorkflowAction, + AiAgentExecutorService, ], - providers: [ScopedWorkspaceContextFactory, AiAgentWorkflowAction], exports: [AiAgentWorkflowAction], }) export class AiAgentActionModule {} diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts index de54128d99..221549e236 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action.ts @@ -1,13 +1,12 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; import { resolveInput } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface'; import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service'; -import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentException, @@ -19,13 +18,14 @@ import { } from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception'; import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input'; import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; +import { AiAgentExecutorService } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service'; import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard'; @Injectable() export class AiAgentWorkflowAction implements WorkflowAction { constructor( - private readonly agentExecutionService: AgentExecutionService, + private readonly aiAgentExecutionService: AiAgentExecutorService, private readonly aiBillingService: AIBillingService, @InjectRepository(AgentEntity, 'core') private readonly agentRepository: Repository, @@ -74,12 +74,13 @@ export class AiAgentWorkflowAction implements WorkflowAction { ); } - const { result, usage } = await this.agentExecutionService.executeAgent({ - agent, - context, - schema: step.settings.outputSchema, - userPrompt: resolveInput(prompt, context) as string, - }); + const { result, usage } = await this.aiAgentExecutionService.executeAgent( + { + agent, + schema: step.settings.outputSchema, + userPrompt: resolveInput(prompt, context) as string, + }, + ); await this.aiBillingService.calculateAndBillUsage( agent?.modelId ?? 'auto', diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service.ts new file mode 100644 index 0000000000..e268e3beeb --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service.ts @@ -0,0 +1,145 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { generateObject, generateText, ToolSet } from 'ai'; +import { Repository } from 'typeorm'; + +import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; +import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; +import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { AgentExecutionResult } from 'src/engine/metadata-modules/agent/agent-execution.service'; +import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; +import { + AgentException, + AgentExceptionCode, +} from 'src/engine/metadata-modules/agent/agent.exception'; +import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const'; +import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const'; +import { convertOutputSchemaToZod } from 'src/engine/metadata-modules/agent/utils/convert-output-schema-to-zod'; +import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type'; + +@Injectable() +export class AiAgentExecutorService { + private readonly logger = new Logger(AiAgentExecutorService.name); + constructor( + private readonly aiModelRegistryService: AiModelRegistryService, + private readonly toolAdapterService: ToolAdapterService, + @InjectRepository(RoleTargetsEntity, 'core') + private readonly roleTargetsRepository: Repository, + @InjectRepository(RoleEntity, 'core') + private readonly roleRepository: Repository, + private readonly toolService: ToolService, + ) {} + + private async getTools( + agentId: string, + workspaceId: string, + ): Promise { + const roleTarget = await this.roleTargetsRepository.findOne({ + where: { + agentId: agentId, + workspaceId, + }, + select: ['roleId'], + }); + + const role = await this.roleRepository.findOne({ + where: { + id: roleTarget?.roleId, + workspaceId, + }, + }); + + if (!roleTarget?.roleId || !role) { + const actionTools = await this.toolAdapterService.getTools(); + + return { ...actionTools }; + } + + const actionTools = await this.toolAdapterService.getTools( + role.id, + workspaceId, + ); + + const databaseTools = await this.toolService.listTools( + role.id, + workspaceId, + ); + + return { + ...databaseTools, + ...actionTools, + }; + } + + async executeAgent({ + agent, + schema, + userPrompt, + }: { + agent: AgentEntity | null; + schema: OutputSchema; + userPrompt: string; + }): Promise { + try { + const registeredModel = + await this.aiModelRegistryService.resolveModelForAgent(agent); + + const tools = agent + ? await this.getTools(agent.id, agent.workspaceId) + : {}; + + this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); + + const textResponse = await generateText({ + system: `You are executing as part of a workflow automation. ${agent ? agent.prompt : ''}`, + tools, + model: registeredModel.model, + prompt: userPrompt, + maxSteps: AGENT_CONFIG.MAX_STEPS, + }); + + if (Object.keys(schema).length === 0) { + return { + result: { response: textResponse.text }, + usage: textResponse.usage, + }; + } + const output = await generateObject({ + system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR, + model: registeredModel.model, + prompt: `Based on the following execution results, generate the structured output according to the schema: + + Execution Results: ${textResponse.text} + + Please generate the structured output based on the execution results and context above.`, + schema: convertOutputSchemaToZod(schema), + }); + + return { + result: output.object, + usage: { + promptTokens: + (textResponse.usage?.promptTokens ?? 0) + + (output.usage?.promptTokens ?? 0), + completionTokens: + (textResponse.usage?.completionTokens ?? 0) + + (output.usage?.completionTokens ?? 0), + totalTokens: + (textResponse.usage?.totalTokens ?? 0) + + (output.usage?.totalTokens ?? 0), + }, + }; + } catch (error) { + if (error instanceof AgentException) { + throw error; + } + throw new AgentException( + error instanceof Error ? error.message : 'Agent execution failed', + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + } +} 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 new file mode 100644 index 0000000000..3139481810 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/schemas/workflow-tool-schemas.ts @@ -0,0 +1,142 @@ +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'), +}); + +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 new file mode 100644 index 0000000000..2edc7f5ac7 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts @@ -0,0 +1,502 @@ +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 { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +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'; +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, + ) {} + + generateWorkflowTools(workspaceId: string, roleId: string): ToolSet { + const tools: ToolSet = {}; + + tools.create_complete_workflow = { + 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.`, + parameters: 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, + roleId, + }); + + const workflowVersionId = await this.createWorkflowVersion({ + workspaceId, + workflowId, + trigger: parameters.trigger, + steps: parameters.steps, + roleId, + }); + + 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, + roleId, + }); + } + + return { + workflowId, + workflowVersionId, + name: parameters.name, + trigger: parameters.trigger, + steps: parameters.steps, + message: `Workflow "${parameters.name}" created successfully with ${parameters.steps.length} steps`, + }; + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to create workflow "${parameters.name}": ${error.message}`, + }; + } + }, + }; + + 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: 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.', + parameters: computeStepOutputSchemaSchema, + execute: async (parameters: { + step: WorkflowTrigger | WorkflowAction; + }) => { + try { + return await this.workflowSchemaService.computeStepOutputSchema({ + step: parameters.step, + workspaceId, + }); + } catch (error) { + return { + success: false, + error: error.message, + message: `Failed to compute step output schema: ${error.message}`, + }; + } + }, + }; + + return tools; + } + + private async createWorkflow({ + workspaceId, + name, + roleId, + }: { + workspaceId: string; + name: string; + roleId: string; + }): Promise { + const workflowRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflow', + { roleId }, + ); + + const workflowPosition = + await this.recordPositionService.buildRecordPosition({ + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflow', + }, + workspaceId, + }); + + const workflow = workflowRepository.create({ + id: uuidv4(), + name, + statuses: [WorkflowStatus.DRAFT], + position: workflowPosition, + }); + + const savedWorkflow = await workflowRepository.save(workflow); + + return savedWorkflow.id; + } + + private async createWorkflowVersion({ + workspaceId, + workflowId, + trigger, + steps, + roleId, + }: { + workspaceId: string; + workflowId: string; + trigger: WorkflowTrigger; + steps: WorkflowAction[]; + roleId: string; + }): Promise { + const workflowVersionRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflowVersion', + { roleId }, + ); + + const versionPosition = + await this.recordPositionService.buildRecordPosition({ + value: 'first', + objectMetadata: { + isCustom: false, + nameSingular: 'workflowVersion', + }, + workspaceId, + }); + + const workflowVersion = workflowVersionRepository.create({ + id: uuidv4(), + workflowId, + name: 'v1', + status: WorkflowVersionStatus.DRAFT, + trigger, + steps, + position: versionPosition, + }); + + const savedWorkflowVersion = + await workflowVersionRepository.save(workflowVersion); + + return savedWorkflowVersion.id; + } + + private async updateWorkflowStatus({ + workspaceId, + workflowId, + workflowVersionId, + roleId, + }: { + workspaceId: string; + workflowId: string; + workflowVersionId: string; + roleId: string; + }) { + const workflowRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'workflow', + { roleId }, + ); + + await workflowRepository.update(workflowId, { + statuses: [WorkflowStatus.ACTIVE], + lastPublishedVersionId: workflowVersionId, + }); + } +} 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 new file mode 100644 index 0000000000..624af0089e --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; + +import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.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'; +import { WorkflowVersionModule } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.module'; +import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/workflow-trigger.module'; + +import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace-service'; + +@Module({ + imports: [ + WorkflowVersionStepModule, + WorkflowVersionEdgeModule, + WorkflowVersionModule, + WorkflowTriggerModule, + WorkflowSchemaModule, + RecordPositionModule, + ], + providers: [WorkflowToolWorkspaceService], + exports: [WorkflowToolWorkspaceService], +}) +export class WorkflowToolsModule {} 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 12a7404992..669468e449 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 @@ -10,7 +10,7 @@ import { setupRepositoryMock, } from './utils/agent-tool-test-utils'; -describe('AgentToolService Integration', () => { +describe('AgentToolGeneratorService Integration', () => { let context: AgentToolTestContext; beforeEach(async () => { @@ -132,21 +132,7 @@ describe('AgentToolService Integration', () => { expect(Object.keys(tools)).toContain('http_request'); }); - it('should return empty tools when role does not exist', async () => { - jest - .spyOn(context.agentService, 'findOneAgent') - .mockResolvedValue(context.testAgent as any); - jest.spyOn(context.roleRepository, 'findOne').mockResolvedValue(null); - - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - - expect(tools).toEqual({}); - }); - - it('should filter out workflow-related objects', async () => { + it('should filter out workflow-run objects', async () => { const workflowObject = { ...context.testObjectMetadata, nameSingular: 'workflow', @@ -187,7 +173,7 @@ describe('AgentToolService Integration', () => { context.testWorkspaceId, ); - expect(Object.keys(tools)).toHaveLength(1); + expect(Object.keys(tools)).toHaveLength(7); }); }); 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 c944eacd9f..93fc919d31 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 @@ -5,11 +5,12 @@ import { type Repository } from 'typeorm'; import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; -import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service'; +import { AgentToolGeneratorService } from 'src/engine/metadata-modules/agent/agent-tool-generator.service'; import { type AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; @@ -19,14 +20,14 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service'; -import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; -import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service'; +import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; +import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; export interface AgentToolTestContext { module: TestingModule; - agentToolService: AgentToolService; + agentToolService: AgentToolGeneratorService; agentService: AgentService; objectMetadataService: ObjectMetadataService; roleRepository: Repository; @@ -48,7 +49,7 @@ export const createAgentToolTestModule = const module = await Test.createTestingModule({ providers: [ - AgentToolService, + AgentToolGeneratorService, { provide: AgentService, useValue: { @@ -126,6 +127,7 @@ export const createAgentToolTestModule = provide: PermissionsService, useValue: { hasToolPermission: jest.fn(), + checkRolePermissions: jest.fn().mockReturnValue(true), }, }, { @@ -142,10 +144,18 @@ export const createAgentToolTestModule = executeHandoff: jest.fn().mockResolvedValue({ success: true }), }, }, + { + provide: WorkflowToolWorkspaceService, + useValue: { + generateWorkflowTools: jest.fn().mockResolvedValue({}), + }, + }, ], }).compile(); - const agentToolService = module.get(AgentToolService); + const agentToolService = module.get( + AgentToolGeneratorService, + ); const agentService = module.get(AgentService); const objectMetadataService = module.get( ObjectMetadataService, diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 83e3791b95..bb9e08d34f 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -8,6 +8,44 @@ */ export { TRIGGER_STEP_ID } from './constants/TriggerStepId'; +export { + objectRecordSchema, + baseWorkflowActionSettingsSchema, + baseWorkflowActionSchema, + baseTriggerSchema, + workflowCodeActionSettingsSchema, + workflowSendEmailActionSettingsSchema, + workflowCreateRecordActionSettingsSchema, + workflowUpdateRecordActionSettingsSchema, + workflowDeleteRecordActionSettingsSchema, + workflowFindRecordsActionSettingsSchema, + workflowFormActionSettingsSchema, + workflowHttpRequestActionSettingsSchema, + workflowAiAgentActionSettingsSchema, + workflowFilterActionSettingsSchema, + workflowCodeActionSchema, + workflowSendEmailActionSchema, + workflowCreateRecordActionSchema, + workflowUpdateRecordActionSchema, + workflowDeleteRecordActionSchema, + workflowFindRecordsActionSchema, + workflowFormActionSchema, + workflowHttpRequestActionSchema, + workflowAiAgentActionSchema, + workflowFilterActionSchema, + workflowActionSchema, + workflowDatabaseEventTriggerSchema, + workflowManualTriggerSchema, + workflowCronTriggerSchema, + workflowWebhookTriggerSchema, + workflowTriggerSchema, + workflowRunStepStatusSchema, + workflowRunStateStepInfoSchema, + workflowRunStateStepInfosSchema, + workflowRunStateSchema, + workflowRunStatusSchema, + workflowRunSchema, +} from './schemas/workflow.schema'; export type { WorkflowRunStepInfo, WorkflowRunStepInfos, diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts similarity index 61% rename from packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts rename to packages/twenty-shared/src/workflow/schemas/workflow.schema.ts index 6aa4838e7c..86cdbe0c75 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-shared/src/workflow/schemas/workflow.schema.ts @@ -1,36 +1,82 @@ -import { FieldMetadataType } from 'twenty-shared/types'; -import { StepStatus } from 'twenty-shared/workflow'; import { z } from 'zod'; +import { FieldMetadataType } from '../../types/FieldMetadataType'; +import { StepLogicalOperator } from '../../types/StepFilters'; +import { ViewFilterOperand } from '../../types/ViewFilterOperand'; +import { StepStatus } from '../types/WorkflowRunStateStepInfos'; // Base schemas -export const objectRecordSchema = z.record(z.any()); +export const objectRecordSchema = z + .record(z.any()) + .describe( + 'Record data object. Use nested objects for relationships (e.g., "company": {"id": "{{reference}}"}). Common patterns:\n' + + '- Person: {"name": {"firstName": "John", "lastName": "Doe"}, "emails": {"primaryEmail": "john@example.com"}, "company": {"id": "{{trigger.object.id}}"}}\n' + + '- Company: {"name": "Acme Corp", "domainName": {"primaryLinkUrl": "https://acme.com"}}\n' + + '- Task: {"title": "Follow up", "status": "TODO", "assignee": {"id": "{{user.id}}"}}', + ); export const baseWorkflowActionSettingsSchema = z.object({ - input: z.object({}).passthrough(), - outputSchema: z.object({}).passthrough(), + input: z + .object({}) + .passthrough() + .describe('Input data for the workflow action. Structure depends on the action type.'), + outputSchema: z + .object({}) + .passthrough() + .describe( + 'Schema defining the output data structure. This data can be referenced in subsequent steps using {{stepId.fieldName}}.', + ), errorHandlingOptions: z.object({ retryOnFailure: z.object({ - value: z.boolean(), + value: z.boolean().describe('Whether to retry the action if it fails.'), }), continueOnFailure: z.object({ - value: z.boolean(), + value: z.boolean().describe('Whether to continue to the next step if this action fails.'), }), }), }); export const baseWorkflowActionSchema = z.object({ - id: z.string(), - name: z.string(), - valid: z.boolean(), - nextStepIds: z.array(z.string()).optional().nullable(), - position: z.object({ x: z.number(), y: z.number() }).optional().nullable(), + id: z + .string() + .describe('Unique identifier for the workflow step. Must be unique within the workflow.'), + name: z + .string() + .describe('Human-readable name for the workflow step. Should clearly describe what the step does.'), + valid: z + .boolean() + .describe('Whether the step configuration is valid. Set to true when all required fields are properly configured.'), + nextStepIds: z + .array(z.string()) + .optional() + .nullable() + .describe('Array of step IDs that this step connects to. Leave empty or null for the final step.'), + position: z + .object({ x: z.number(), y: z.number() }) + .optional() + .nullable() + .describe('Position coordinates for the step in the workflow diagram.'), }); export const baseTriggerSchema = z.object({ - name: z.string().optional(), - type: z.string(), - position: z.object({ x: z.number(), y: z.number() }).optional().nullable(), - nextStepIds: z.array(z.string()).optional().nullable(), + name: z + .string() + .optional() + .describe('Human-readable name for the trigger. Optional but recommended for clarity.'), + type: z + .enum(['DATABASE_EVENT', 'MANUAL', 'CRON', 'WEBHOOK']) + .describe( + 'Type of trigger. DATABASE_EVENT for record changes, MANUAL for user-initiated, CRON for scheduled, WEBHOOK for external calls.', + ), + position: z + .object({ x: z.number(), y: z.number() }) + .optional() + .nullable() + .describe('Position coordinates for the trigger in the workflow diagram. Use (0, 0) for the trigger step.'), + nextStepIds: z + .array(z.string()) + .optional() + .nullable() + .describe('Array of step IDs that the trigger connects to. These are the first steps in the workflow.'), }); // Action settings schemas @@ -56,8 +102,15 @@ export const workflowSendEmailActionSettingsSchema = export const workflowCreateRecordActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ input: z.object({ - objectName: z.string(), - objectRecord: objectRecordSchema, + objectName: z + .string() + .describe( + 'The name of the object to create a record in. Must be lowercase (e.g., "person", "company", "task").', + ), + objectRecord: objectRecordSchema + .describe( + 'The record data to create.', + ) }), }); @@ -146,8 +199,23 @@ export const workflowAiAgentActionSettingsSchema = export const workflowFilterActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ input: z.object({ - stepFilterGroups: z.array(z.any()), - stepFilters: z.array(z.any()), + stepFilterGroups: z.array(z.object({ + id: z.string(), + logicalOperator: z.nativeEnum(StepLogicalOperator), + parentStepFilterGroupId: z.string().optional(), + positionInStepFilterGroup: z.number().optional(), + })), + stepFilters: z.array(z.object({ + id: z.string(), + type: z.string(), + stepOutputKey: z.string(), + operand: z.nativeEnum(ViewFilterOperand), + value: z.string(), + stepFilterGroupId: z.string(), + positionInStepFilterGroup: z.number().optional(), + fieldMetadataId: z.string().optional(), + compositeFieldSubFieldName: z.string().optional(), + })), }), }); @@ -162,26 +230,20 @@ export const workflowSendEmailActionSchema = baseWorkflowActionSchema.extend({ settings: workflowSendEmailActionSettingsSchema, }); -export const workflowCreateRecordActionSchema = baseWorkflowActionSchema.extend( - { - type: z.literal('CREATE_RECORD'), - settings: workflowCreateRecordActionSettingsSchema, - }, -); +export const workflowCreateRecordActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('CREATE_RECORD'), + settings: workflowCreateRecordActionSettingsSchema, +}); -export const workflowUpdateRecordActionSchema = baseWorkflowActionSchema.extend( - { - type: z.literal('UPDATE_RECORD'), - settings: workflowUpdateRecordActionSettingsSchema, - }, -); +export const workflowUpdateRecordActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('UPDATE_RECORD'), + settings: workflowUpdateRecordActionSettingsSchema, +}); -export const workflowDeleteRecordActionSchema = baseWorkflowActionSchema.extend( - { - type: z.literal('DELETE_RECORD'), - settings: workflowDeleteRecordActionSettingsSchema, - }, -); +export const workflowDeleteRecordActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('DELETE_RECORD'), + settings: workflowDeleteRecordActionSettingsSchema, +}); export const workflowFindRecordsActionSchema = baseWorkflowActionSchema.extend({ type: z.literal('FIND_RECORDS'), @@ -226,23 +288,45 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ export const workflowDatabaseEventTriggerSchema = baseTriggerSchema.extend({ type: z.literal('DATABASE_EVENT'), settings: z.object({ - eventName: z.string(), + eventName: z + .string() + .regex( + /^[a-z][a-z0-9_]*\.(created|updated|deleted)$/, + 'Event name must follow the pattern: objectName.action (e.g., "company.created", "person.updated")', + ) + .describe( + 'Event name in format: objectName.action (e.g., "company.created", "person.updated", "task.deleted"). Use lowercase object names.', + ), input: z.object({}).passthrough().optional(), - outputSchema: z.object({}).passthrough(), + outputSchema: z + .object({}) + .passthrough() + .describe( + 'Schema defining the output data structure. For database events, this includes the record that triggered the workflow accessible via {{trigger.object.fieldName}}.', + ), objectType: z.string().optional(), fields: z.array(z.string()).optional().nullable(), }), -}); +}).describe( + 'Database event trigger that fires when a record is created, updated, or deleted. The triggered record is accessible in workflow steps via {{trigger.object.fieldName}}.', +); export const workflowManualTriggerSchema = baseTriggerSchema.extend({ type: z.literal('MANUAL'), settings: z.object({ objectType: z.string().optional(), - outputSchema: z.object({}).passthrough(), + outputSchema: z + .object({}) + .passthrough() + .describe( + 'Schema defining the output data structure. When a record is selected, it is accessible via {{trigger.record.fieldName}}. When no record is selected, no data is available.', + ), icon: z.string().optional(), isPinned: z.boolean().optional(), }), -}); +}).describe( + 'Manual trigger that can be launched by the user. If a record is selected when launched, it is accessible via {{trigger.record.fieldName}}. If no record is selected, no data context is available.', +); export const workflowCronTriggerSchema = baseTriggerSchema.extend({ type: z.literal('CRON'),