diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index e3fd0693b5..8ce63ac011 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -1,8 +1,14 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; +import { + parseRecordReference, + RECORD_REFERENCE_REGEX, + RecordLink, +} from '@/ai/components/RecordLink'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { lazy, Suspense } from 'react'; +import { Fragment, lazy, Suspense, useMemo } from 'react'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; +import { isDefined } from 'twenty-shared/utils'; const MarkdownRenderer = lazy(async () => { const [{ default: Markdown }, { default: remarkGfm }] = await Promise.all([ @@ -96,12 +102,87 @@ const LoadingSkeleton = () => { ); }; +const useTextWithRecordLinks = (text: string) => { + return useMemo(() => { + const parts: Array< + | string + | { type: 'record'; props: ReturnType } + > = []; + let lastIndex = 0; + + RECORD_REFERENCE_REGEX.lastIndex = 0; + + let match; + + while ((match = RECORD_REFERENCE_REGEX.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + + const parsed = parseRecordReference(match[0]); + + if (isDefined(parsed)) { + parts.push({ type: 'record', props: parsed }); + } + + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts; + }, [text]); +}; + export const LazyMarkdownRenderer = ({ text }: { text: string }) => { + const parts = useTextWithRecordLinks(text); + + // If there are no record references, render normally + const hasRecordReferences = parts.some( + (part) => typeof part === 'object' && part.type === 'record', + ); + + if (!hasRecordReferences) { + return ( + }> + + {text} + + + ); + } + + // Render with record links inline return ( }> - - {text} - + {parts.map((part, index) => { + if (typeof part === 'string') { + return ( + + {part} + + ); + } + + if (part.type === 'record' && isDefined(part.props)) { + return ( + + + + ); + } + + return null; + })} ); }; diff --git a/packages/twenty-front/src/modules/ai/components/RecordLink.tsx b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx new file mode 100644 index 0000000000..1ee1d59474 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/components/RecordLink.tsx @@ -0,0 +1,62 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; +import { isNonEmptyString } from '@sniptt/guards'; +import { AvatarChip, ChipVariant, LinkChip } from 'twenty-ui/components'; + +type RecordLinkProps = { + objectNameSingular: string; + recordId: string; + displayName: string; +}; + +export const RecordLink = ({ + objectNameSingular, + recordId, + displayName, +}: RecordLinkProps) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + if (!objectMetadataItem || !isNonEmptyString(recordId)) { + return {displayName}; + } + + const linkToShowPage = getLinkToShowPage(objectNameSingular, { + id: recordId, + }); + + return ( + + } + /> + ); +}; + +export const RECORD_REFERENCE_REGEX = + /\[\[record:([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/g; + +export const parseRecordReference = (match: string) => { + const regex = /\[\[record:([a-zA-Z]+):([a-f0-9-]+):([^\]]+)\]\]/; + const result = regex.exec(match); + + if (!result) { + return null; + } + + return { + objectNameSingular: result[1], + recordId: result[2], + displayName: result[3], + }; +}; diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index accc26a635..564f86fabb 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -53,12 +53,36 @@ const StyledToggleButton = styled.div<{ isExpandable: boolean }>` gap: ${({ theme }) => theme.spacing(1)}; padding: ${({ theme }) => theme.spacing(1)} 0; transition: color ${({ theme }) => theme.animation.duration.normal}s; + justify-content: space-between; + width: 100%; &:hover { color: ${({ theme }) => theme.font.color.secondary}; } `; +const StyledToolName = styled.span` + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.border.radius.xs}; + color: ${({ theme }) => theme.font.color.light}; + font-family: ${({ theme }) => theme.font.family}; + font-size: ${({ theme }) => theme.font.size.xs}; + padding: ${({ theme }) => theme.spacing(0.5)} + ${({ theme }) => theme.spacing(1)}; +`; + +const StyledLeftContent = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledRightContent = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing(2)}; +`; + const StyledDisplayMessage = styled.span` color: ${({ theme }) => theme.font.color.tertiary}; font-size: ${({ theme }) => theme.font.size.md}; @@ -120,13 +144,20 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { if (!output && !hasError) { return ( - - - - {getToolDisplayMessage(input, toolName, false)} - - - + + + + + + {getToolDisplayMessage(input, toolName, false)} + + + + + + {toolName} + + ); } @@ -153,16 +184,21 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { onClick={() => setIsExpanded(!isExpanded)} isExpandable={isExpandable} > - - - {displayMessage} - - {isExpandable && - (isExpanded ? ( - - ) : ( - - ))} + + + + {displayMessage} + + + + {toolName} + {isExpandable && + (isExpanded ? ( + + ) : ( + + ))} + {isExpandable && ( diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts index 586a398c31..6d17999ee2 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getLabelIdentifierFieldValue.ts @@ -1,4 +1,3 @@ -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isDefined } from 'twenty-shared/utils'; @@ -7,17 +6,13 @@ import { FieldMetadataType } from '~/generated-metadata/graphql'; export const getLabelIdentifierFieldValue = ( record: ObjectRecord, labelIdentifierFieldMetadataItem: FieldMetadataItem | undefined, - objectNameSingular: string, ): string => { if (!isDefined(labelIdentifierFieldMetadataItem)) { return record.id; } const recordIdentifierValue = record[labelIdentifierFieldMetadataItem.name]; - if ( - objectNameSingular === CoreObjectNameSingular.WorkspaceMember || - labelIdentifierFieldMetadataItem.type === FieldMetadataType.FULL_NAME - ) { + if (labelIdentifierFieldMetadataItem.type === FieldMetadataType.FULL_NAME) { return `${recordIdentifierValue?.firstName ?? ''} ${recordIdentifierValue?.lastName ?? ''}`; } diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index a25dec8711..e37b87539a 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -27,7 +27,6 @@ export const getObjectRecordIdentifier = ({ const labelIdentifierFieldValue = getLabelIdentifierFieldValue( record, labelIdentifierFieldMetadataItem, - objectMetadataItem.nameSingular, ); const imageIdentifierFieldMetadata = objectMetadataItem.fields.find( diff --git a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx index f877c8a6a2..1dd0bd1d1b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/virtualization/components/RecordTableRowVirtualizedDebugRowHelper.tsx @@ -59,17 +59,12 @@ export const RecordTableRowVirtualizedDebugRowHelper = ({ (RECORD_TABLE_ROW_HEIGHT + 1); const record = useRecoilValue(recordStoreFamilyState(recordId ?? '')); - const { objectMetadataItem, objectNameSingular } = - useRecordTableContextOrThrow(); + const { objectMetadataItem } = useRecordTableContextOrThrow(); const labelIdentifierFieldMetadataItem = getLabelIdentifierFieldMetadataItem(objectMetadataItem); const labelIdentifier = isDefined(record) - ? getLabelIdentifierFieldValue( - record, - labelIdentifierFieldMetadataItem, - objectNameSingular, - ) + ? getLabelIdentifierFieldValue(record, labelIdentifierFieldMetadataItem) : '-'; const position = record?.position; diff --git a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts index 04f66503c9..f91f71d8fd 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getRecordChipGenerators.ts @@ -87,7 +87,6 @@ export const getRecordChipGenerators = ( name: getLabelIdentifierFieldValue( record, labelIdentifierFieldMetadataItemToUse, - objectMetadataItemToUse.nameSingular, ), avatarUrl: getAvatarUrl( objectMetadataItemToUse.nameSingular, diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPageTitle.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPageTitle.tsx index cb2a98b9e2..b5de1560e4 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPageTitle.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPageTitle.tsx @@ -21,11 +21,7 @@ export const RecordShowPageTitle = ({ const record = useRecoilValue(recordStoreFamilyState(objectRecordId)); const pageName = isDefined(record) - ? getLabelIdentifierFieldValue( - record, - labelIdentifierFieldMetadataItem, - objectNameSingular, - ) + ? getLabelIdentifierFieldValue(record, labelIdentifierFieldMetadataItem) : ''; const pageTitle = pageName.trim() diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts index 93ce3b01a8..18844afe89 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-record.service.ts @@ -9,6 +9,7 @@ import { RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; @@ -137,6 +138,17 @@ export class CreateRecordService { success: true, message: `Record created successfully in ${objectName}`, result: createdRecord, + recordReferences: [ + { + objectNameSingular: objectName, + recordId: createdRecord.id, + displayName: getRecordDisplayName( + { ...transformedObjectRecord, ...createdRecord }, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + }, + ], }; } catch (error) { if (error instanceof RecordCrudException) { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts index a174cfde68..85803d172e 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts @@ -19,6 +19,7 @@ import { } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; import { FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; @@ -114,6 +115,16 @@ export class FindRecordsService { this.logger.log(`Found ${records.length} records in ${objectName}`); + const recordReferences = records.map((record) => ({ + objectNameSingular: objectName, + recordId: record.id as string, + displayName: getRecordDisplayName( + record, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + })); + return { success: true, message: `Found ${records.length} ${objectName} records`, @@ -121,6 +132,7 @@ export class FindRecordsService { records, count: totalCount, }, + recordReferences, }; } catch (error) { this.logger.error(`Failed to find records: ${error}`); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts index 4699e2598e..f04222b0c7 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-record.service.ts @@ -9,6 +9,7 @@ import { RecordCrudExceptionCode, } from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util'; import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; @@ -167,6 +168,17 @@ export class UpdateRecordService { success: true, message: `Record updated successfully in ${objectName}`, result: updatedObjectRecord, + recordReferences: [ + { + objectNameSingular: objectName, + recordId: objectRecordId, + displayName: getRecordDisplayName( + updatedObjectRecord, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + }, + ], }; } catch (error) { if (error instanceof RecordCrudException) { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-display-name.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-display-name.util.ts new file mode 100644 index 0000000000..c85934850d --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/get-record-display-name.util.ts @@ -0,0 +1,46 @@ +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +// Mirrors frontend's getLabelIdentifierFieldValue logic +export const getRecordDisplayName = ( + record: Record, + flatObjectMetadata: FlatObjectMetadata, + flatFieldMetadataMaps: FlatEntityMaps, +): string => { + const { labelIdentifierFieldMetadataId } = flatObjectMetadata; + + if (!isDefined(labelIdentifierFieldMetadataId)) { + return String(record.id ?? 'Unknown'); + } + + const labelIdentifierField = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: labelIdentifierFieldMetadataId, + }); + + if (!isDefined(labelIdentifierField)) { + return String(record.id ?? 'Unknown'); + } + + const fieldValue = record[labelIdentifierField.name]; + + // Handle FULL_NAME composite type (person, workspaceMember) + if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) { + const nameValue = fieldValue as + | { firstName?: string; lastName?: string } + | undefined; + const firstName = nameValue?.firstName ?? ''; + const lastName = nameValue?.lastName ?? ''; + + return `${firstName} ${lastName}`.trim() || String(record.id) || 'Unknown'; + } + + return isDefined(fieldValue) + ? String(fieldValue) + : String(record.id ?? 'Unknown'); +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts b/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts index 87d6a7603b..bfcd303743 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-generator/services/per-object-tool-generator.service.ts @@ -10,10 +10,6 @@ import { type ToolGeneratorContext, } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; -import { - type ToolHints, - type ToolOperation, -} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; @@ -32,12 +28,10 @@ export class PerObjectToolGeneratorService { async generate( context: ToolGeneratorContext, factories: ToolFactory[], - toolHints?: ToolHints, ): Promise { - const objects = await this.getFilteredObjectsWithPermissions( + const objects = await this.getObjectsWithPermissions( context.workspaceId, context.rolePermissionConfig, - toolHints, ); const tools: ToolSet = {}; @@ -55,11 +49,10 @@ export class PerObjectToolGeneratorService { return tools; } - // Get workspace objects with their permissions, filtered by toolHints - async getFilteredObjectsWithPermissions( + // Get workspace objects with their permissions + async getObjectsWithPermissions( workspaceId: string, rolePermissionConfig: RolePermissionConfig, - toolHints?: ToolHints, ): Promise { const { rolesPermissions } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ @@ -110,41 +103,13 @@ export class PerObjectToolGeneratorService { })); // Filter out workflow-related objects - let filteredObjectMetadata = allObjectMetadata.filter( + const filteredObjectMetadata = allObjectMetadata.filter( (objectMetadata) => !isWorkflowRelatedObject(objectMetadata), ); - // Apply toolHints filtering if provided - if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) { - const relevantSet = new Set(toolHints.relevantObjects); - const originalCount = filteredObjectMetadata.length; - - filteredObjectMetadata = filteredObjectMetadata.filter( - (obj) => - relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural), - ); - - this.logger.log( - `Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`, - ); - - if (filteredObjectMetadata.length === 0) { - this.logger.warn( - `Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`, - ); - } - } - // Map to ObjectWithPermission const result: ObjectWithPermission[] = []; - const operationsSet = toolHints?.operations - ? new Set(toolHints.operations) - : null; - - const shouldIncludeOperation = (operation: ToolOperation) => - !operationsSet || operationsSet.has(operation); - for (const objectMetadata of filteredObjectMetadata) { const permission = objectPermissions[objectMetadata.id]; @@ -155,24 +120,13 @@ export class PerObjectToolGeneratorService { result.push({ objectMetadata, restrictedFields: permission.restrictedFields, - canCreate: - shouldIncludeOperation('create') && permission.canUpdateObjectRecords, - canRead: - shouldIncludeOperation('find') && permission.canReadObjectRecords, - canUpdate: - shouldIncludeOperation('update') && permission.canUpdateObjectRecords, - canDelete: - shouldIncludeOperation('delete') && - permission.canSoftDeleteObjectRecords, + canCreate: permission.canUpdateObjectRecords, + canRead: permission.canReadObjectRecords, + canUpdate: permission.canUpdateObjectRecords, + canDelete: permission.canSoftDeleteObjectRecords, }); } - if (operationsSet) { - this.logger.log( - `Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`, - ); - } - return result; } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts index 596d886c38..29730d6d65 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-provider.service.ts @@ -159,7 +159,6 @@ export class ToolProviderService { actorContext: spec.actorContext, }, [factory], - spec.toolHints, ); } @@ -227,7 +226,6 @@ export class ToolProviderService { await this.workflowToolService.generateRecordStepConfiguratorTools( spec.workspaceId, spec.rolePermissionConfig, - spec.toolHints, ); return { ...workflowTools, ...recordStepTools }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts new file mode 100644 index 0000000000..e156c19de4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -0,0 +1,565 @@ +import { Inject, Injectable, Logger, Optional } from '@nestjs/common'; + +import { type ToolSet } from 'ai'; +import { PermissionFlagType } from 'twenty-shared/constants'; +import { type ActorMetadata } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { z } from 'zod'; + +import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; +import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; +import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; +import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; +import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory'; +import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; +import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; +import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool'; +import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; +import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; + +export type ToolIndexEntry = { + name: string; + description: string; + category: 'database' | 'action' | 'workflow' | 'metadata'; + objectName?: string; + operation?: string; +}; + +export type ToolSearchOptions = { + limit?: number; + category?: 'database' | 'action' | 'workflow' | 'metadata'; +}; + +export type ToolContext = { + workspaceId: string; + roleId: string; + actorContext?: ActorMetadata; +}; + +// Workflow tool definitions for the index (static metadata) +const WORKFLOW_TOOLS_METADATA: Array<{ name: string; description: string }> = [ + { + name: 'create_complete_workflow', + description: + 'Create a complete workflow with trigger, steps, and connections in a single operation', + }, + { + name: 'create_workflow_version_step', + description: 'Create a new step in a workflow version', + }, + { + name: 'update_workflow_version_step', + description: 'Update an existing step in a workflow version', + }, + { + name: 'delete_workflow_version_step', + description: 'Delete a step from a workflow version', + }, + { + name: 'create_workflow_version_edge', + description: 'Create a connection (edge) between two workflow steps', + }, + { + name: 'delete_workflow_version_edge', + description: 'Delete a connection (edge) between workflow steps', + }, + { + name: 'create_draft_from_workflow_version', + description: 'Create a new draft workflow version from an existing one', + }, + { + name: 'update_workflow_version_positions', + description: 'Update the positions of multiple workflow steps', + }, + { + name: 'activate_workflow_version', + description: + 'Activate a workflow version to make it available for execution', + }, + { + name: 'deactivate_workflow_version', + description: 'Deactivate a workflow version', + }, + { + name: 'compute_step_output_schema', + description: 'Compute the output schema for a workflow step', + }, +]; + +@Injectable() +export class ToolRegistryService { + private readonly logger = new Logger(ToolRegistryService.name); + + constructor( + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly permissionsService: PermissionsService, + private readonly httpTool: HttpTool, + private readonly sendEmailTool: SendEmailTool, + private readonly searchArticlesTool: SearchArticlesTool, + private readonly createRecordService: CreateRecordService, + private readonly updateRecordService: UpdateRecordService, + private readonly deleteRecordService: DeleteRecordService, + private readonly findRecordsService: FindRecordsService, + @Optional() + @Inject(WORKFLOW_TOOL_SERVICE_TOKEN) + private readonly workflowToolService: WorkflowToolWorkspaceService | null, + ) {} + + async buildToolIndex( + workspaceId: string, + roleId: string, + ): Promise { + const index: ToolIndexEntry[] = []; + + const actionTools = await this.getActionToolIndex(workspaceId, roleId); + + index.push(...actionTools); + + const databaseTools = await this.getDatabaseToolIndex(workspaceId, roleId); + + index.push(...databaseTools); + + if (this.workflowToolService) { + const workflowTools = this.getWorkflowToolIndex(); + + index.push(...workflowTools); + } + + this.logger.log( + `Built tool index with ${index.length} tools for workspace ${workspaceId}`, + ); + + return index; + } + + private getWorkflowToolIndex(): ToolIndexEntry[] { + return WORKFLOW_TOOLS_METADATA.map((tool) => ({ + name: tool.name, + description: tool.description, + category: 'workflow' as const, + })); + } + + async searchTools( + query: string, + workspaceId: string, + roleId: string, + options: ToolSearchOptions = {}, + ): Promise { + const { limit = 5, category } = options; + const index = await this.buildToolIndex(workspaceId, roleId); + + const queryLower = query.toLowerCase(); + const queryTerms = queryLower + .split(/\s+/) + .filter((term) => term.length > 2); + + const scored = index + .filter((tool) => !category || tool.category === category) + .map((tool) => { + let score = 0; + const nameLower = tool.name.toLowerCase(); + const descLower = tool.description.toLowerCase(); + const objectLower = tool.objectName?.toLowerCase() ?? ''; + + // Exact name match - highest priority + if (nameLower.includes(queryLower)) { + score += 100; + } + + // Object name match - high priority + if (objectLower && queryLower.includes(objectLower)) { + score += 80; + } + + // Term matches in name + for (const term of queryTerms) { + if (nameLower.includes(term)) { + score += 30; + } + if (objectLower.includes(term)) { + score += 25; + } + if (descLower.includes(term)) { + score += 10; + } + } + + // Operation keyword matches + const operations = ['find', 'create', 'update', 'delete', 'search']; + + for (const op of operations) { + if (queryLower.includes(op) && nameLower.includes(op)) { + score += 40; + } + } + + return { tool, score }; + }) + .filter((item) => item.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit) + .map((item) => item.tool); + + this.logger.log( + `Tool search for "${query}" returned ${scored.length} results`, + ); + + return scored; + } + + async getToolsByName( + names: string[], + context: ToolContext, + ): Promise { + const tools: ToolSet = {}; + const rolePermissionConfig: RolePermissionConfig = { + intersectionOf: [context.roleId], + }; + + for (const name of names) { + const tool = await this.getToolByName( + name, + context.workspaceId, + rolePermissionConfig, + context.actorContext, + ); + + if (tool) { + tools[name] = tool; + } + } + + return tools; + } + + private async getToolByName( + name: string, + workspaceId: string, + rolePermissionConfig: RolePermissionConfig, + actorContext?: ActorMetadata, + ): Promise { + const actionTool = this.getActionToolByName(name, workspaceId); + + if (actionTool) { + return actionTool; + } + + const workflowTool = await this.getWorkflowToolByName( + name, + workspaceId, + rolePermissionConfig, + ); + + if (workflowTool) { + return workflowTool; + } + + const match = name.match( + /^(find|find_one|create|update|soft_delete)_(.+)$/, + ); + + if (match) { + const [, _operation, objectName] = match; + const dbTools = await this.getDatabaseToolsForObject( + workspaceId, + rolePermissionConfig, + objectName, + actorContext, + ); + + return dbTools[name] ?? null; + } + + return null; + } + + private async getWorkflowToolByName( + name: string, + workspaceId: string, + rolePermissionConfig: RolePermissionConfig, + ): Promise { + if (!this.workflowToolService) { + return null; + } + + const isWorkflowTool = WORKFLOW_TOOLS_METADATA.some( + (tool) => tool.name === name, + ); + + if (!isWorkflowTool) { + return null; + } + + // Generate workflow tools and return the requested one + const workflowTools = this.workflowToolService.generateWorkflowTools( + workspaceId, + rolePermissionConfig, + ); + + return workflowTools[name] ?? null; + } + + private async getActionToolIndex( + workspaceId: string, + roleId: string, + ): Promise { + const index: ToolIndexEntry[] = []; + const rolePermissionConfig: RolePermissionConfig = { + intersectionOf: [roleId], + }; + + // HTTP Request tool + const hasHttpPermission = await this.permissionsService.hasToolPermission( + rolePermissionConfig, + workspaceId, + PermissionFlagType.HTTP_REQUEST_TOOL, + ); + + if (hasHttpPermission) { + index.push({ + name: 'http_request', + description: this.httpTool.description, + category: 'action', + }); + } + + // Send Email tool + const hasEmailPermission = await this.permissionsService.hasToolPermission( + rolePermissionConfig, + workspaceId, + PermissionFlagType.SEND_EMAIL_TOOL, + ); + + if (hasEmailPermission) { + index.push({ + name: 'send_email', + description: this.sendEmailTool.description, + category: 'action', + }); + } + + index.push({ + name: 'search_articles', + description: this.searchArticlesTool.description, + category: 'action', + }); + + return index; + } + + private getActionToolByName( + name: string, + workspaceId: string, + ): ToolSet[string] | null { + switch (name) { + case 'http_request': + return { + description: this.httpTool.description, + inputSchema: this.httpTool.inputSchema, + execute: async (parameters: { + input: z.infer['input']; + }) => this.httpTool.execute(parameters.input, workspaceId), + }; + case 'send_email': + return { + description: this.sendEmailTool.description, + inputSchema: this.sendEmailTool.inputSchema, + execute: async (parameters: { + input: z.infer['input']; + }) => this.sendEmailTool.execute(parameters.input, workspaceId), + }; + case 'search_articles': + return { + description: this.searchArticlesTool.description, + inputSchema: this.searchArticlesTool.inputSchema, + execute: async (parameters: { + input: z.infer['input']; + }) => this.searchArticlesTool.execute(parameters.input), + }; + default: + return null; + } + } + + private async getDatabaseToolIndex( + workspaceId: string, + roleId: string, + ): Promise { + const index: ToolIndexEntry[] = []; + + const { rolesPermissions } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'rolesPermissions', + ]); + + const objectPermissions = rolesPermissions[roleId]; + + if (!objectPermissions) { + return index; + } + + const { flatObjectMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps'], + }, + ); + + const allFlatObjects = Object.values(flatObjectMetadataMaps.byId) + .filter(isDefined) + .filter((obj) => obj.isActive && !obj.isSystem); + + for (const flatObject of allFlatObjects) { + if (isWorkflowRelatedObject(flatObject)) { + continue; + } + + const permission = objectPermissions[flatObject.id]; + + if (!permission) { + continue; + } + + const objectName = flatObject.nameSingular; + const objectLabel = flatObject.labelSingular; + + if (permission.canReadObjectRecords) { + index.push({ + name: `find_${objectName}`, + description: `Search and find ${objectLabel} records`, + category: 'database', + objectName, + operation: 'find', + }); + + index.push({ + name: `find_one_${objectName}`, + description: `Get a single ${objectLabel} record by ID`, + category: 'database', + objectName, + operation: 'find_one', + }); + } + + if (permission.canUpdateObjectRecords) { + index.push({ + name: `create_${objectName}`, + description: `Create new ${objectLabel} records`, + category: 'database', + objectName, + operation: 'create', + }); + + index.push({ + name: `update_${objectName}`, + description: `Update existing ${objectLabel} records`, + category: 'database', + objectName, + operation: 'update', + }); + } + + if (permission.canSoftDeleteObjectRecords) { + index.push({ + name: `soft_delete_${objectName}`, + description: `Soft delete ${objectLabel} records`, + category: 'database', + objectName, + operation: 'soft_delete', + }); + } + } + + return index; + } + + private async getDatabaseToolsForObject( + workspaceId: string, + rolePermissionConfig: RolePermissionConfig, + objectName: string, + actorContext?: ActorMetadata, + ): Promise { + const { rolesPermissions } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'rolesPermissions', + ]); + + let objectPermissions; + + if ('intersectionOf' in rolePermissionConfig) { + const allRolePermissions = rolePermissionConfig.intersectionOf.map( + (roleId: string) => rolesPermissions[roleId], + ); + + objectPermissions = + allRolePermissions.length === 1 + ? allRolePermissions[0] + : computePermissionIntersection(allRolePermissions); + } else { + return {}; + } + + const { flatObjectMetadataMaps, flatFieldMetadataMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], + }, + ); + + const flatObject = Object.values(flatObjectMetadataMaps.byId) + .filter(isDefined) + .find((obj) => obj.nameSingular === objectName); + + if (!flatObject) { + return {}; + } + + const permission = objectPermissions[flatObject.id]; + + if (!permission) { + return {}; + } + + const objectMetadata = { + ...flatObject, + fields: getFlatFieldsFromFlatObjectMetadata( + flatObject, + flatFieldMetadataMaps, + ), + }; + + const factory = createDirectRecordToolsFactory({ + createRecordService: this.createRecordService, + updateRecordService: this.updateRecordService, + deleteRecordService: this.deleteRecordService, + findRecordsService: this.findRecordsService, + }); + + return factory( + { + objectMetadata, + restrictedFields: permission.restrictedFields, + canCreate: permission.canUpdateObjectRecords, + canRead: permission.canReadObjectRecords, + canUpdate: permission.canUpdateObjectRecords, + canDelete: permission.canSoftDeleteObjectRecords, + }, + { + workspaceId, + rolePermissionConfig, + actorContext, + }, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts index 037a562cf5..d45a2c1b3e 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tool-provider.module.ts @@ -6,10 +6,13 @@ import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { ToolProviderService } from './services/tool-provider.service'; +import { ToolRegistryService } from './services/tool-registry.service'; // NOTE: This module does NOT import WorkflowToolsModule to avoid circular dependency: // ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule @@ -30,8 +33,10 @@ import { ToolProviderService } from './services/tool-provider.service'; ObjectMetadataModule, FieldMetadataModule, PermissionsModule, + WorkspaceCacheModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, ], - providers: [ToolProviderService], - exports: [ToolProviderService], + providers: [ToolProviderService, ToolRegistryService], + exports: [ToolProviderService, ToolRegistryService], }) export class ToolProviderModule {} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/agent-search.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/agent-search.tool.ts new file mode 100644 index 0000000000..7e8b8e9e8a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/agent-search.tool.ts @@ -0,0 +1,61 @@ +import { z } from 'zod'; + +import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; + +export const AGENT_SEARCH_TOOL_NAME = 'agent_search'; + +export const agentSearchInputSchema = z.object({ + input: z.object({ + query: z.string().describe('What kind of expertise or help you need'), + limit: z + .number() + .optional() + .default(2) + .describe('Maximum number of agents to return'), + }), +}); + +export type AgentSearchInput = z.infer['input']; + +export type AgentSearchResult = { + agents: Array<{ + name: string; + label: string; + expertise: string; + }>; + message: string; +}; + +export type AgentSearchFunction = ( + query: string, + options: { limit: number }, +) => Promise; + +export const createAgentSearchTool = (searchAgents: AgentSearchFunction) => ({ + description: + 'Search for agent expertise/skills to help with specialized tasks. Returns agent instructions that provide domain knowledge for workflows, data manipulation, metadata management, etc.', + inputSchema: agentSearchInputSchema, + execute: async (parameters: { + input: AgentSearchInput; + }): Promise => { + const { query, limit = 2 } = parameters.input; + + const agents = await searchAgents(query, { limit }); + + if (agents.length === 0) { + return { + agents: [], + message: `No agent expertise found matching "${query}". Try searching for: "workflow", "data", "metadata", "dashboard", or "research".`, + }; + } + + return { + agents: agents.map((agent) => ({ + name: agent.name, + label: agent.label, + expertise: agent.prompt, + })), + message: `Found ${agents.length} agent(s) with relevant expertise. Their instructions are included above to help guide your approach.`, + }; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/index.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/index.ts new file mode 100644 index 0000000000..49ee91a1e4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/index.ts @@ -0,0 +1,17 @@ +export { + createLoadToolsTool, + LOAD_TOOLS_TOOL_NAME, + loadToolsInputSchema, + type LoadToolsInput, + type LoadToolsResult, + type DynamicToolStore, +} from './load-tools.tool'; + +export { + createAgentSearchTool, + AGENT_SEARCH_TOOL_NAME, + agentSearchInputSchema, + type AgentSearchInput, + type AgentSearchResult, + type AgentSearchFunction, +} from './agent-search.tool'; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-tools.tool.ts new file mode 100644 index 0000000000..55e0e39542 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-tools.tool.ts @@ -0,0 +1,77 @@ +import { z } from 'zod'; + +import { + type ToolContext, + type ToolRegistryService, +} from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; + +export const LOAD_TOOLS_TOOL_NAME = 'load_tools' as const; + +export const loadToolsInputSchema = z.object({ + input: z.object({ + toolNames: z + .array(z.string()) + .describe( + 'Array of tool names to load. Use the exact names from the tool catalog.', + ), + }), +}); + +export type LoadToolsInput = z.infer['input']; + +export type LoadToolsResult = { + loaded: string[]; + notFound: string[]; + message: string; +}; + +export type DynamicToolStore = { + loadedTools: Set; +}; + +export const createLoadToolsTool = ( + toolRegistry: ToolRegistryService, + context: ToolContext, + dynamicToolStore: DynamicToolStore, + onToolsLoaded: (toolNames: string[]) => Promise, +) => ({ + description: `Load tools by name to make them available for use. Call this when you need to use a tool from the catalog that isn't already loaded. You can load multiple tools at once.`, + inputSchema: loadToolsInputSchema, + execute: async (parameters: { + input: LoadToolsInput; + }): Promise => { + const { toolNames } = parameters.input; + + const loaded: string[] = []; + const notFound: string[] = []; + + const tools = await toolRegistry.getToolsByName(toolNames, context); + + for (const name of toolNames) { + if (tools[name]) { + loaded.push(name); + dynamicToolStore.loadedTools.add(name); + } else { + notFound.push(name); + } + } + + if (loaded.length > 0) { + await onToolsLoaded(loaded); + } + + if (notFound.length > 0) { + return { + loaded, + notFound, + message: `Loaded ${loaded.length} tool(s). Could not find: ${notFound.join(', ')}. Check the tool catalog for correct names.`, + }; + } + + return { + loaded, + notFound: [], + message: `Successfully loaded ${loaded.length} tool(s): ${loaded.join(', ')}. These tools are now available for use.`, + }; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts index 43b2ef1c17..edd781c205 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-specification.type.ts @@ -1,7 +1,6 @@ import { type ActorMetadata } from 'twenty-shared/types'; import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; @@ -11,6 +10,5 @@ export type ToolSpecification = { rolePermissionConfig?: RolePermissionConfig; actorContext?: ActorMetadata; agent?: FlatAgentWithRoleId | null; - toolHints?: ToolHints; wrapWithErrorContext?: boolean; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts index 63da96d85d..ab62d07452 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts @@ -1,3 +1,9 @@ +export type RecordReference = { + objectNameSingular: string; + recordId: string; + displayName: string; +}; + export type ToolOutput = { success: boolean; message: string; @@ -6,4 +12,6 @@ export type ToolOutput = { status?: number; statusText?: string; headers?: Record; + // Record references for linking to created/found records + recordReferences?: RecordReference[]; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts index 05fa094884..5861d73cc5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module.ts @@ -18,9 +18,6 @@ import { AgentMessageEntity } from './entities/agent-message.entity'; import { AgentTurnEntity } from './entities/agent-turn.entity'; import { AgentActorContextService } from './services/agent-actor-context.service'; import { AgentAsyncExecutorService } from './services/agent-async-executor.service'; -import { AgentExecutionService } from './services/agent-execution.service'; -import { AgentPlanExecutorService } from './services/agent-plan-executor.service'; -import { AgentToolGeneratorService } from './services/agent-tool-generator.service'; @Module({ imports: [ @@ -41,18 +38,9 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi RoleTargetEntity, ]), ], - providers: [ - AgentAsyncExecutorService, - AgentExecutionService, - AgentToolGeneratorService, - AgentActorContextService, - AgentPlanExecutorService, - ], + providers: [AgentAsyncExecutorService, AgentActorContextService], exports: [ AgentAsyncExecutorService, - AgentExecutionService, - AgentPlanExecutorService, - AgentToolGeneratorService, AgentActorContextService, TypeOrmModule.forFeature([ AgentMessageEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts index 7ddf1160e1..686a2ea484 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts @@ -19,7 +19,7 @@ import { AgentExceptionCode, } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; -import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; +import { WORKFLOW_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; @@ -141,7 +141,7 @@ export class AgentAsyncExecutorService { this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); const textResponse = await generateText({ - system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.WORKFLOW_ADDITIONS}\n\n${agent ? agent.prompt : ''}`, + system: `${WORKFLOW_SYSTEM_PROMPTS.BASE}\n\n${agent ? agent.prompt : ''}`, tools, model: registeredModel.model, prompt: userPrompt, @@ -177,7 +177,7 @@ export class AgentAsyncExecutorService { } const output = await generateObject({ - system: AGENT_SYSTEM_PROMPTS.OUTPUT_GENERATOR, + system: WORKFLOW_SYSTEM_PROMPTS.OUTPUT_GENERATOR, model: registeredModel.model, prompt: `Based on the following execution results, generate the structured output according to the schema: diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts deleted file mode 100644 index 34277e7088..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service.ts +++ /dev/null @@ -1,411 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { - convertToModelMessages, - stepCountIs, - streamText, - ToolSet, - UIDataTypes, - UIMessage, - UITools, -} from 'ai'; -import { AppPath, type ActorMetadata } from 'twenty-shared/types'; -import { getAppPath } from 'twenty-shared/utils'; -import { In } from 'typeorm'; - -import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils'; -import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { - AgentException, - AgentExceptionCode, -} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; -import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; -import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; -import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; -import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; -import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; -import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; -import { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; -import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; -import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; -import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; - -import { AgentActorContextService } from './agent-actor-context.service'; -import { AgentToolGeneratorService } from './agent-tool-generator.service'; - -// Re-export for backward compatibility -export { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type'; - -export interface StreamChatResponseResult { - stream: ReturnType; - timings: { - contextBuildTimeMs: number; - toolGenerationTimeMs: number; - aiRequestPrepTimeMs: number; - toolCount: number; - }; -} - -@Injectable() -export class AgentExecutionService { - private readonly logger = new Logger(AgentExecutionService.name); - - constructor( - private readonly workspaceDomainsService: WorkspaceDomainsService, - private readonly twentyORMGlobalManager: TwentyORMGlobalManager, - private readonly aiModelRegistryService: AiModelRegistryService, - private readonly agentToolGeneratorService: AgentToolGeneratorService, - private readonly agentModelConfigService: AgentModelConfigService, - private readonly aiBillingService: AIBillingService, - private readonly agentActorContextService: AgentActorContextService, - private readonly agentService: AgentService, - private readonly workspaceCacheService: WorkspaceCacheService, - ) {} - - async prepareAIRequestConfig({ - messages, - system, - agent, - actorContext, - roleIds, - toolHints, - additionalTools, - }: { - system: string; - agent: FlatAgentWithRoleId | null; - messages: UIMessage[]; - actorContext?: ActorMetadata; - roleIds?: string[]; - toolHints?: ToolHints; - additionalTools?: ToolSet; - }) { - try { - if (agent) { - this.logger.log( - `Preparing AI request config for agent ${agent.id} with model ${agent.modelId}`, - ); - } - - const registeredModel = - await this.aiModelRegistryService.resolveModelForAgent(agent); - - let tools: ToolSet = {}; - let providerOptions; - - if (agent) { - const baseTools = - await this.agentToolGeneratorService.generateToolsForAgent( - agent.id, - agent.workspaceId, - actorContext, - roleIds, - toolHints, - ); - - const nativeModelTools = - this.agentModelConfigService.getNativeModelTools( - registeredModel, - agent, - ); - - tools = { - ...baseTools, - ...nativeModelTools, - ...(additionalTools || {}), - }; - - providerOptions = this.agentModelConfigService.getProviderOptions( - registeredModel, - agent, - ); - } - - this.logger.log( - `Generated ${Object.keys(tools).length} tools for agent (including ${Object.keys(additionalTools || {}).length} additional tools)`, - ); - - return { - system, - tools, - model: registeredModel.model, - messages: convertToModelMessages(messages), - stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS), - providerOptions, - experimental_telemetry: AI_TELEMETRY_CONFIG, - experimental_repairToolCall: async ({ - toolCall, - tools: toolsForRepair, - inputSchema, - error, - }: { - toolCall: { - type: 'tool-call'; - toolCallId: string; - toolName: string; - input: string; - }; - tools: Record; - inputSchema: (toolCall: { toolName: string }) => unknown; - error: Error; - }) => { - return repairToolCall({ - toolCall, - tools: toolsForRepair, - inputSchema, - error, - model: registeredModel.model, - }); - }, - }; - } catch (error) { - this.logger.error( - `Failed to prepare AI request config for agent ${agent?.id ?? 'no agent'}`, - error instanceof Error ? error.stack : error, - ); - throw error; - } - } - - async getContextForSystemPrompt( - workspace: WorkspaceEntity, - recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType, - userWorkspaceId: string, - ) { - const { userWorkspaceRoleMap } = - await this.workspaceCacheService.getOrRecompute(workspace.id, [ - 'userWorkspaceRoleMap', - ]); - - const roleId = userWorkspaceRoleMap[userWorkspaceId]; - - if (!roleId) { - throw new AgentException( - 'Failed to retrieve user role.', - AgentExceptionCode.ROLE_NOT_FOUND, - ); - } - - const workspaceDataSource = - await this.twentyORMGlobalManager.getDataSourceForWorkspace({ - workspaceId: workspace.id, - }); - - const flatObjectMetadataMaps = - workspaceDataSource.internalContext.flatObjectMetadataMaps; - const flatFieldMetadataMaps = - workspaceDataSource.internalContext.flatFieldMetadataMaps; - const objectIdByNameSingular = - workspaceDataSource.internalContext.objectIdByNameSingular; - const objectMetadataPermissions = workspaceDataSource.permissionsPerRoleId; - - const contextObject = ( - await Promise.all( - recordIdsByObjectMetadataNameSingular.map( - async (recordsWithObjectMetadataNameSingular) => { - if (recordsWithObjectMetadataNameSingular.recordIds.length === 0) { - return []; - } - - const objectMetadataId = - objectIdByNameSingular[ - recordsWithObjectMetadataNameSingular.objectMetadataNameSingular - ]; - const objectMetadataMapItem = objectMetadataId - ? flatObjectMetadataMaps.byId[objectMetadataId] - : undefined; - - if (!objectMetadataMapItem) { - this.logger.warn( - `Object metadata not found for ${recordsWithObjectMetadataNameSingular.objectMetadataNameSingular}`, - ); - - return []; - } - - const repository = workspaceDataSource.getRepository( - recordsWithObjectMetadataNameSingular.objectMetadataNameSingular, - { unionOf: [roleId] }, - ); - - const restrictedFields = - objectMetadataPermissions?.[roleId]?.[objectMetadataMapItem.id] - ?.restrictedFields ?? {}; - - const hasRestrictedFields = Object.values(restrictedFields).some( - (field) => field.canRead === false, - ); - - const selectOptions = hasRestrictedFields - ? getAllSelectableColumnNames({ - restrictedFields, - objectMetadata: { - objectMetadataMapItem, - flatFieldMetadataMaps, - }, - }) - : undefined; - - return ( - await repository.find({ - ...(selectOptions && { select: selectOptions }), - where: { - id: In(recordsWithObjectMetadataNameSingular.recordIds), - }, - }) - ).map((record) => { - return { - ...record, - resourceUrl: this.workspaceDomainsService.buildWorkspaceURL({ - workspace, - pathname: getAppPath(AppPath.RecordShowPage, { - objectNameSingular: - recordsWithObjectMetadataNameSingular.objectMetadataNameSingular, - objectRecordId: record.id, - }), - }), - }; - }); - }, - ), - ) - ).flat(2); - - return JSON.stringify(contextObject); - } - - async streamChatResponse({ - workspace, - userWorkspaceId, - agentId, - messages, - recordIdsByObjectMetadataNameSingular, - toolHints, - additionalTools, - }: { - workspace: WorkspaceEntity; - userWorkspaceId: string; - agentId: string; - messages: UIMessage[]; - recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; - toolHints?: ToolHints; - additionalTools?: ToolSet; - }): Promise<{ - stream: ReturnType; - timings: { - contextBuildTimeMs: number; - toolGenerationTimeMs: number; - aiRequestPrepTimeMs: number; - toolCount: number; - }; - contextInfo: { - contextString: string; - contextRecordCount: number; - contextSizeBytes: number; - }; - }> { - try { - const agent = await this.agentService.findOneAgentById({ - workspaceId: workspace.id, - id: agentId, - }); - - const contextBuildStart = Date.now(); - let contextPart = ''; - let contextRecordCount = 0; - - if (recordIdsByObjectMetadataNameSingular.length > 0) { - contextPart = await this.getContextForSystemPrompt( - workspace, - recordIdsByObjectMetadataNameSingular, - userWorkspaceId, - ); - - try { - const contextData = JSON.parse(contextPart); - - contextRecordCount = Array.isArray(contextData) - ? contextData.length - : 0; - } catch (error) { - this.logger.warn('Failed to parse context for record count:', error); - } - } - - const contextString = contextPart ? `\n\nCONTEXT:\n${contextPart}` : ''; - const contextBuildTime = Date.now() - contextBuildStart; - - const { actorContext, roleId } = - await this.agentActorContextService.buildUserAndAgentActorContext( - userWorkspaceId, - workspace.id, - ); - - const aiRequestPrepStart = Date.now(); - - const aiRequestConfig = await this.prepareAIRequestConfig({ - system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.CHAT_ADDITIONS}\n\n${agent.prompt}${contextString}`, - agent, - messages, - actorContext, - roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])], - toolHints, - additionalTools, - }); - - const aiRequestPrepTime = Date.now() - aiRequestPrepStart; - const toolCount = Object.keys(aiRequestConfig.tools || {}).length; - const toolGenerationTime = aiRequestPrepTime; - - this.logger.log( - `Sending request to AI model with ${messages.length} messages and ${toolCount} tools`, - ); - - const model = - await this.aiModelRegistryService.resolveModelForAgent(agent); - - const stream = streamText(aiRequestConfig); - - stream.usage - .then((usage) => { - this.aiBillingService.calculateAndBillUsage( - model.modelId, - usage, - workspace.id, - agent.id, - ); - }) - .catch((usageError) => { - this.logger.error('Failed to get usage information:', usageError); - }); - - return { - stream, - timings: { - contextBuildTimeMs: contextBuildTime, - toolGenerationTimeMs: toolGenerationTime, - aiRequestPrepTimeMs: aiRequestPrepTime, - toolCount, - }, - contextInfo: { - contextString: contextPart, - contextRecordCount, - contextSizeBytes: contextPart - ? Buffer.byteLength(contextPart, 'utf8') - : 0, - }, - }; - } catch (error) { - this.logger.error('Error in streamChatResponse:', error); - throw new AgentException( - error instanceof Error - ? error.message - : 'Failed to stream chat response', - AgentExceptionCode.AGENT_EXECUTION_FAILED, - ); - } - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts deleted file mode 100644 index 22bc00dfba..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; -import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; -import { type PlanStep } from 'src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface'; -import { STANDARD_AGENT_DEFINITIONS } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/standard-agent-definitions'; - -import { AgentExecutionService } from './agent-execution.service'; - -export type PlanExecutionProgress = { - type: 'plan-generated' | 'step-started' | 'step-completed'; - stepNumber?: number; - agentName?: string; - task?: string; - output?: string; - totalSteps?: number; - reasoning?: string; -}; - -export type StepResult = { - stepNumber: number; - agentName: string; - output: string; -}; - -export type PlanExecutionResult = { - finalOutput: string; - stepResults: StepResult[]; -}; - -@Injectable() -export class AgentPlanExecutorService { - private readonly logger = new Logger(AgentPlanExecutorService.name); - - constructor( - private readonly agentExecutionService: AgentExecutionService, - private readonly agentService: AgentService, - ) {} - - async executePlan({ - steps, - reasoning, - workspace, - userWorkspaceId, - recordIdsByObjectMetadataNameSingular, - onProgress, - writer, - }: { - steps: PlanStep[]; - reasoning: string; - workspace: WorkspaceEntity; - userWorkspaceId: string; - recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; - onProgress?: (progress: PlanExecutionProgress) => void; - writer?: { - write: (chunk: unknown) => void; - merge: (stream: unknown) => void; - }; - }): Promise { - this.logger.log(`Executing plan with ${steps.length} steps`); - - onProgress?.({ - type: 'plan-generated', - totalSteps: steps.length, - reasoning, - }); - - const stepResults: StepResult[] = []; - - for (const step of steps) { - try { - this.logger.log( - `[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`, - ); - - const agent = await this.agentService.findOneAgentByName({ - name: step.agentName, - workspaceId: workspace.id, - }); - - this.logger.log( - `[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`, - ); - - onProgress?.({ - type: 'step-started', - stepNumber: step.stepNumber, - agentName: step.agentName, - task: step.task, - }); - - const dependencyOutputs = this.gatherDependencyOutputs( - step, - stepResults, - ); - - const promptWithContext = this.buildStepPrompt(step, dependencyOutputs); - - const { stream: stepStream } = - await this.agentExecutionService.streamChatResponse({ - workspace, - agentId: agent.id, - userWorkspaceId, - messages: [ - { - id: `step-${step.stepNumber}`, - role: 'user' as const, - parts: [{ type: 'text' as const, text: promptWithContext }], - }, - ], - recordIdsByObjectMetadataNameSingular, - }); - - let stepOutput = ''; - - if (writer) { - writer.merge( - stepStream.toUIMessageStream({ - onError: (error) => { - return error instanceof Error ? error.message : String(error); - }, - sendStart: false, - onFinish: async ({ responseMessage }) => { - stepOutput = responseMessage.parts - .filter((part) => part.type === 'text') - .map((part) => { - if (part.type === 'text') { - return part.text; - } - - return ''; - }) - .join(''); - }, - }), - ); - - await stepStream.text; - } else { - stepOutput = await stepStream.text; - } - - stepResults.push({ - stepNumber: step.stepNumber, - agentName: step.agentName, - output: stepOutput, - }); - - onProgress?.({ - type: 'step-completed', - stepNumber: step.stepNumber, - agentName: step.agentName, - output: stepOutput, - }); - - this.logger.log( - `Completed step ${step.stepNumber}: ${step.task.substring(0, 50)}...`, - ); - } catch (error) { - this.logger.error( - `Failed to execute step ${step.stepNumber}: ${step.task}`, - error, - ); - - throw new Error( - `Plan execution failed at step ${step.stepNumber}: ${error.message}`, - ); - } - } - - const finalOutput = this.synthesizeResults(stepResults, steps); - - return { - finalOutput, - stepResults, - }; - } - - private gatherDependencyOutputs( - step: PlanStep, - previousResults: StepResult[], - ): string { - if (!step.dependsOn || step.dependsOn.length === 0) { - return ''; - } - - const dependencyOutputs = step.dependsOn - .map((depStepNum) => { - const depResult = previousResults.find( - (result) => result.stepNumber === depStepNum, - ); - - if (!depResult) { - throw new Error( - `Dependency step ${depStepNum} not found for step ${step.stepNumber}`, - ); - } - - return `Step ${depStepNum} output:\n${depResult.output}`; - }) - .join('\n\n'); - - return dependencyOutputs; - } - - private buildStepPrompt(step: PlanStep, dependencyOutputs: string): string { - let prompt = `Task: ${step.task}\n\nExpected output: ${step.expectedOutput}`; - - if (dependencyOutputs) { - prompt += `\n\nPrevious step results:\n${dependencyOutputs}`; - } - - return prompt; - } - - private synthesizeResults( - stepResults: StepResult[], - steps: PlanStep[], - ): string { - const lastStep = stepResults[stepResults.length - 1]; - - if (!lastStep) { - return 'No results produced'; - } - - const lastStepDefinition = steps.find( - (s) => s.stepNumber === lastStep.stepNumber, - ); - - if (lastStepDefinition) { - const agentDefinition = STANDARD_AGENT_DEFINITIONS.find( - (def) => def.name === lastStepDefinition.agentName, - ); - - if (agentDefinition?.outputStrategy === 'direct') { - return lastStep.output; - } - } - - const summary = stepResults - .map((result) => { - const step = steps.find((s) => s.stepNumber === result.stepNumber); - - return `**Step ${result.stepNumber}: ${step?.task || 'Unknown task'}**\n${result.output}`; - }) - .join('\n\n---\n\n'); - - return summary; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts deleted file mode 100644 index bcf3859a85..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { type ToolSet } from 'ai'; - -import type { ActorMetadata } from 'twenty-shared/types'; - -import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; -import type { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; - -@Injectable() -export class AgentToolGeneratorService { - private readonly logger = new Logger(AgentToolGeneratorService.name); - - constructor(private readonly toolProvider: ToolProviderService) {} - - // Generates base tools for chat context (DATABASE_CRUD and ACTION) - // Additional tools (WORKFLOW, METADATA) are provided via additionalTools - // from ChatToolsProviderService to avoid circular dependencies - async generateToolsForAgent( - agentId: string, - workspaceId: string, - actorContext?: ActorMetadata, - roleIds?: string[], - toolHints?: ToolHints, - ): Promise { - try { - return await this.toolProvider.getTools({ - workspaceId, - categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION], - rolePermissionConfig: roleIds ? { intersectionOf: roleIds } : undefined, - actorContext, - toolHints, - wrapWithErrorContext: true, - }); - } catch (toolError) { - const errorMessage = - toolError instanceof Error ? toolError.message : 'Unknown error'; - - this.logger.warn( - `Failed to generate tools for agent ${agentId}: ${errorMessage}. Proceeding without tools.`, - ); - - return {}; - } - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts index 648883aded..65941b057c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/agent.service.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { ILike, IsNull, Repository } from 'typeorm'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { type CreateAgentInput } from 'src/engine/metadata-modules/ai/ai-agent/dtos/create-agent.input'; @@ -300,4 +300,26 @@ export class AgentService { roleId: roleId ?? null, }; } + + async searchAgents( + query: string, + workspaceId: string, + options: { limit: number } = { limit: 2 }, + ): Promise { + const queryLower = query.toLowerCase(); + + return this.agentRepository.find({ + where: [ + { workspaceId, deletedAt: IsNull(), name: ILike(`%${queryLower}%`) }, + { + workspaceId, + deletedAt: IsNull(), + description: ILike(`%${queryLower}%`), + }, + { workspaceId, deletedAt: IsNull(), label: ILike(`%${queryLower}%`) }, + ], + take: options.limit, + order: { name: 'ASC' }, + }); + } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts index 52ac73e8ea..58de6f04eb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts @@ -1,107 +1,25 @@ -export const AGENT_SYSTEM_PROMPTS = { - BASE: `Tool usage strategy: +// System prompts for Workflow Agents (automated execution only) +// NOTE: For user-facing chat, use CHAT_SYSTEM_PROMPTS from ai-chat/constants + +export const WORKFLOW_SYSTEM_PROMPTS = { + // Core workflow execution behavior + BASE: `You are executing as part of a workflow automation in Twenty CRM. + +Tool usage strategy: - Chain multiple tools to solve complex tasks - If a tool fails, try alternative approaches - Use results from one tool to inform the next - Don't give up after first failure - be persistent -- Validate assumptions before making changes -Error recovery: -- Analyze error messages to understand what went wrong -- Adjust parameters or try different tools -- Only give up after exhausting reasonable alternatives +Context: +- Your output may be used by downstream workflow nodes +- Be thorough and include all relevant data +- Focus on completing the task efficiently Permissions: -- Only perform actions your role allows -- Explain limitations if you lack permissions`, - - CHAT_ADDITIONS: ` -Format responses with markdown for clarity (headings, lists, code blocks, tables).`, - - WORKFLOW_ADDITIONS: ` -Context: -- You are executing as part of a workflow automation -- Your output may be used by downstream nodes -- Be thorough and include all relevant data`, - - ROUTER: ( - agentDescriptions: string, - ) => `You are an AI router that decides how to handle user messages. - -Available agents: -${agentDescriptions} - -Decision process: -1. Can ONE agent handle this entirely? → Use "simple" strategy -2. Does it require MULTIPLE agents working together? → Use "planned" strategy - -Agent selection rules (CRITICAL): -- **metadata-builder**: For modifying the DATA MODEL/SCHEMA - creating new object types, adding fields to objects, managing object structure. Use when user wants to define NEW TYPES of entities or add properties to existing types. -- **data-manipulator**: For CRUD operations on existing RECORDS/DATA - creating company records, finding people, updating opportunities. Use when user wants to work with actual data entries. -- **helper**: ONLY for questions about HOW TO USE Twenty (features, setup, documentation) -- **researcher**: For finding external information from the web -- **workflow-builder**: For creating automation workflows -- **dashboard-builder**: For creating and managing dashboards and visualizations - -CRITICAL DISTINCTION: -- "Create an object called Project" → metadata-builder (creating a new object TYPE in the schema) -- "Create a company called Acme" → data-manipulator (creating a company RECORD) -- "Add a field to Company" → metadata-builder (modifying schema) -- "Update the company's phone number" → data-manipulator (modifying data) - -Use "planned" strategy when: -- Request needs custom code AND context from data/research -- Code generation requires knowing schemas, APIs, or external data -- Multiple specialized capabilities must combine (code + data + research) - -Use "simple" strategy for: -- Single-agent tasks (data operations, research, documentation lookup) -- Standard workflow creation (no custom code needed) - -Examples: - -Simple: "Show me all companies with >100 employees" -→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company"], operations: ["find"] } } - -Simple: "Create 30 companies in the automobile industry with 2 people each" -→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company", "person"], operations: ["create"] } } - -Simple: "Update all opportunities in stage 'Qualified' to 'Proposal'" -→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["opportunity"], operations: ["find", "update"] } } - -Simple: "What's the latest news about AI trends?" -→ { strategy: "simple", agentName: "researcher" } - -Simple: "How do I set up email sync in Twenty?" -→ { strategy: "simple", agentName: "helper" } - -Simple: "Create a workflow that emails customers when deals close" -→ { strategy: "simple", agentName: "workflow-builder" } - -Simple: "Create an object called Project" or "Create a new custom object for tracking invoices" -→ { strategy: "simple", agentName: "metadata-builder" } - -Simple: "Add a budget field to the Project object" or "Add a phone field to Company" -→ { strategy: "simple", agentName: "metadata-builder" } - -Planned: "Research information about Meta and update the company record" -→ { - strategy: "planned", - plan: { - steps: [ - { stepNumber: 1, agentName: "researcher", task: "Look up current information about Meta (employee count, headquarters, revenue, etc.)", expectedOutput: "Company facts and data" }, - { stepNumber: 2, agentName: "data-manipulator", task: "Update the Meta company record with the researched information", expectedOutput: "Updated company record", dependsOn: [1] } - ], - reasoning: "Requires web research followed by database update" - } -} - -For simple strategy toolHints: -- relevantObjects: Extract object names (e.g., ["company", "person"]) -- operations: ["find", "create", "update", "delete"] - -Keep plans minimal and only use planning when truly necessary.`, +- Only perform actions your role allows`, + // Structured output generation for workflow data passing OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema. Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions. diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts deleted file mode 100644 index d417d326da..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module'; -import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module'; - -import { AiChatRouterService } from './ai-chat-router.service'; - -import { AiChatRouterPlanGeneratorService } from './services/ai-chat-router-plan-generator.service'; -import { AiChatRouterStrategyDeciderService } from './services/ai-chat-router-strategy-decider.service'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([AgentEntity, WorkspaceEntity]), - AiModelsModule, - ObjectMetadataModule, - ], - providers: [ - AiChatRouterService, - AiChatRouterStrategyDeciderService, - AiChatRouterPlanGeneratorService, - ], - exports: [AiChatRouterService], -}) -export class AiChatRouterModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service.ts deleted file mode 100644 index e6c2d94aa2..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service.ts +++ /dev/null @@ -1,391 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { type UIDataTypes, type UIMessage, type UITools } from 'ai'; -import { IsNull, type Repository } from 'typeorm'; - -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util'; -import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; -import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent'; - -import { AiChatRouterPlanGeneratorService } from './services/ai-chat-router-plan-generator.service'; -import { - AiChatRouterStrategyDeciderService, - type StrategyDecision, -} from './services/ai-chat-router-strategy-decider.service'; -import { - type RouterDebugInfo, - type UnifiedRouterResult, -} from './types/router-result.interface'; -import { type ToolHints } from './types/tool-hints.interface'; - -export interface AiChatRouterContext { - messages: UIMessage[]; - workspaceId: string; - fastModel: ModelId; - smartModel: ModelId; -} - -@Injectable() -export class AiChatRouterService { - private readonly logger = new Logger(AiChatRouterService.name); - - constructor( - @InjectRepository(AgentEntity) - private readonly agentRepository: Repository, - private readonly strategyDecider: AiChatRouterStrategyDeciderService, - private readonly planGenerator: AiChatRouterPlanGeneratorService, - private readonly objectMetadataService: ObjectMetadataService, - ) {} - - async routeMessage( - context: AiChatRouterContext, - includeDebugInfo = false, - ): Promise { - try { - const { messages, workspaceId, fastModel, smartModel } = context; - const availableAgents = await this.getAvailableAgents(workspaceId); - - this.logger.log( - `[ROUTER] Available agents (${availableAgents.length}): ${availableAgents.map((a) => `${a.label} (${a.name})`).join(', ')}`, - ); - - if (availableAgents.length === 0) { - return await this.handleNoAgentsAvailable(workspaceId); - } - - const debugInfo = this.createDebugInfo( - includeDebugInfo, - availableAgents, - smartModel, - fastModel, - ); - - if (availableAgents.length === 1) { - return this.createSimpleResult(availableAgents[0], debugInfo); - } - - return await this.routeToMultipleAgents({ - messages, - workspaceId, - availableAgents, - fastModel, - smartModel, - debugInfo, - }); - } catch (error) { - return await this.handleRoutingError(error, context.workspaceId); - } - } - - private async handleNoAgentsAvailable( - workspaceId: string, - ): Promise { - this.logger.warn('No agents available for routing'); - - const helperAgent = await this.getHelperAgent(workspaceId); - - if (!helperAgent) { - throw new Error('No helper agent available'); - } - - return { - strategy: 'simple', - agent: helperAgent, - }; - } - - private createDebugInfo( - includeDebugInfo: boolean, - availableAgents: AgentEntity[], - smartModel: ModelId, - fastModel: ModelId, - ): RouterDebugInfo | undefined { - if (!includeDebugInfo) { - return undefined; - } - - return { - availableAgents: availableAgents.map((agent) => ({ - id: agent.id, - label: agent.label, - })), - routerModel: String(smartModel ?? fastModel), - promptTokens: 0, - completionTokens: 0, - totalTokens: 0, - }; - } - - private createSimpleResult( - agent: AgentEntity, - debugInfo?: RouterDebugInfo, - toolHints?: ToolHints, - ): UnifiedRouterResult { - return { - strategy: 'simple', - agent, - toolHints, - debugInfo, - }; - } - - private async routeToMultipleAgents(params: { - messages: UIMessage[]; - workspaceId: string; - availableAgents: AgentEntity[]; - fastModel: ModelId; - smartModel: ModelId; - debugInfo?: RouterDebugInfo; - }): Promise { - const { - messages, - workspaceId, - availableAgents, - fastModel, - smartModel, - debugInfo, - } = params; - - const workspaceObjectsList = - await this.buildWorkspaceObjectsList(workspaceId); - const agentDescriptions = this.buildAgentDescriptions( - availableAgents, - workspaceObjectsList, - ); - - this.logRoutingContext(messages, agentDescriptions); - - const strategyDecision = await this.strategyDecider.decideStrategy({ - messages, - availableAgents, - agentDescriptions, - fastModel, - }); - - if (strategyDecision.strategy === 'simple') { - return this.handleSimpleStrategy( - strategyDecision, - availableAgents, - debugInfo, - ); - } - - return await this.handlePlannedStrategy({ - messages, - availableAgents, - agentDescriptions, - smartModel, - debugInfo, - }); - } - - private logRoutingContext( - messages: UIMessage[], - agentDescriptions: string, - ) { - this.logger.log(`[ROUTER] Agent descriptions:\n${agentDescriptions}`); - - const currentMessage = - messages[messages.length - 1]?.parts.find((part) => part.type === 'text') - ?.text || ''; - - this.logger.log( - `[ROUTER] User message: "${currentMessage.substring(0, 100)}..."`, - ); - } - - private handleSimpleStrategy( - strategyDecision: StrategyDecision, - availableAgents: AgentEntity[], - debugInfo?: RouterDebugInfo, - ): UnifiedRouterResult { - if (!strategyDecision.agentName) { - throw new Error('agentName is required for simple strategy'); - } - - const selectedAgent = this.findAgentByName( - strategyDecision.agentName, - availableAgents, - ); - - this.logger.log( - `[ROUTER] Routing to ${selectedAgent.label} (${selectedAgent.name})`, - ); - - return this.createSimpleResult( - selectedAgent, - debugInfo, - strategyDecision.toolHints, - ); - } - - private async handlePlannedStrategy(params: { - messages: UIMessage[]; - availableAgents: AgentEntity[]; - agentDescriptions: string; - smartModel: ModelId; - debugInfo?: RouterDebugInfo; - }): Promise { - const { - messages, - availableAgents, - agentDescriptions, - smartModel, - debugInfo, - } = params; - - const plan = await this.planGenerator.generatePlan({ - messages, - availableAgents, - agentDescriptions, - smartModel, - }); - - if (plan.steps.length === 1) { - return this.convertSingleStepPlanToSimple( - plan.steps[0], - availableAgents, - debugInfo, - ); - } - - this.logger.log( - `[ROUTER] Executing planned strategy with ${plan.steps.length} steps`, - ); - - return { - strategy: 'planned', - plan, - debugInfo, - }; - } - - private convertSingleStepPlanToSimple( - step: { agentName: string }, - availableAgents: AgentEntity[], - debugInfo?: RouterDebugInfo, - ): UnifiedRouterResult { - this.logger.log( - `[ROUTER] Plan has only 1 step, converting to simple strategy`, - ); - - const selectedAgent = this.findAgentByName(step.agentName, availableAgents); - - return this.createSimpleResult(selectedAgent, debugInfo); - } - - private findAgentByName( - agentName: string, - availableAgents: AgentEntity[], - ): AgentEntity { - const selectedAgent = availableAgents.find( - (agent) => agent.name === agentName, - ); - - if (!selectedAgent) { - this.logger.error( - `[ROUTER] Agent "${agentName}" not found in available agents: ${availableAgents.map((a) => a.name).join(', ')}`, - ); - throw new Error(`Selected agent ${agentName} not found`); - } - - return selectedAgent; - } - - private async handleRoutingError( - error: unknown, - workspaceId: string, - ): Promise { - this.logger.error( - 'Routing with planning failed, falling back to Helper agent:', - error, - ); - - const helperAgent = await this.getHelperAgent(workspaceId); - - if (!helperAgent) { - throw new Error('No helper agent available for fallback'); - } - - return { - strategy: 'simple', - agent: helperAgent, - }; - } - - private async getAvailableAgents( - workspaceId: string, - ): Promise { - const agents = await this.agentRepository.find({ - where: { workspaceId, deletedAt: IsNull() }, - order: { createdAt: 'ASC' }, - }); - - return agents.filter( - (agent) => !agent.name.includes('workflow-service-agent'), - ); - } - - private async getHelperAgent(workspaceId: string) { - const helperAgent = await this.agentRepository.findOne({ - where: { - workspaceId, - standardId: HELPER_AGENT.standardId, - }, - }); - - return helperAgent; - } - - private async buildWorkspaceObjectsList( - workspaceId: string, - ): Promise { - try { - const objects = await this.objectMetadataService.findManyWithinWorkspace( - workspaceId, - { - where: { isActive: true, isSystem: false }, - }, - ); - - const filteredObjects = objects.filter( - (obj) => !isWorkflowRelatedObject(obj), - ); - - if (filteredObjects.length === 0) { - return ''; - } - - return filteredObjects - .map((obj) => `- ${obj.labelSingular} (${obj.nameSingular})`) - .join('\n'); - } catch (error) { - this.logger.warn('Failed to build workspace objects list:', error); - - return ''; - } - } - - private buildAgentDescriptions( - agents: AgentEntity[], - workspaceObjectsList: string, - ): string { - const agentDescriptions = agents - .map((agent) => { - return `- ${agent.label} (${agent.name}): ${agent.description}`; - }) - .join('\n'); - - if (workspaceObjectsList) { - return `${agentDescriptions} - -Available workspace objects for data-manipulator: -${workspaceObjectsList}`; - } - - return agentDescriptions; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-plan-generator.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-plan-generator.service.ts deleted file mode 100644 index 612a22042d..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-plan-generator.service.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { - generateObject, - type UIDataTypes, - type UIMessage, - type UITools, -} from 'ai'; -import { z } from 'zod'; - -import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { type ExecutionPlan } from 'src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface'; -import { - DEFAULT_SMART_MODEL, - type ModelId, -} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; -import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; -import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; - -@Injectable() -export class AiChatRouterPlanGeneratorService { - private readonly logger = new Logger(AiChatRouterPlanGeneratorService.name); - - constructor( - private readonly aiModelRegistryService: AiModelRegistryService, - ) {} - - async generatePlan({ - messages, - availableAgents, - agentDescriptions, - smartModel, - }: { - messages: UIMessage[]; - availableAgents: AgentEntity[]; - agentDescriptions: string; - smartModel: ModelId; - }): Promise { - const model = this.getSmartModel(smartModel); - const agentNames = availableAgents.map((agent) => agent.name); - - const conversationHistory = messages - .slice(0, -1) - .map((msg) => { - const textContent = - msg.parts.find((part) => part.type === 'text')?.text || ''; - - return `${msg.role}: ${textContent}`; - }) - .join('\n'); - - const currentMessage = - messages[messages.length - 1]?.parts.find((part) => part.type === 'text') - ?.text || ''; - - const systemPrompt = `You are an AI planner that creates execution plans for multi-agent tasks. - -Available agents: -${agentDescriptions} - -Create a step-by-step execution plan. Each step should: -- Assign to the most appropriate agent -- Have a clear, specific task -- Specify expected output -- List dependencies on previous steps (if any) - -Keep plans focused and efficient.`; - - const userPrompt = `${conversationHistory ? `Conversation history:\n${conversationHistory}\n\n` : ''}Current request:\n${currentMessage}\n\nCreate a detailed execution plan with specific steps.`; - - const planStepSchema = z.object({ - stepNumber: z.number().describe('Step number in execution order'), - agentName: z - .enum([agentNames[0], ...agentNames.slice(1)]) - .describe('Agent name to execute this step'), - task: z.string().describe('Specific task for this agent'), - expectedOutput: z.string().describe('Expected output from this step'), - dependsOn: z - .array(z.number()) - .optional() - .describe('Step numbers this step depends on'), - }); - - const planSchema = z.object({ - steps: z.array(planStepSchema).describe('Execution steps in order'), - reasoning: z.string().describe('Why multi-agent planning is needed'), - }); - - const PLANNER_TEMPERATURE = 0.1; - - const result = await generateObject({ - model, - system: systemPrompt, - prompt: userPrompt, - schema: planSchema, - temperature: PLANNER_TEMPERATURE, - experimental_telemetry: AI_TELEMETRY_CONFIG, - }); - - this.logger.log( - `[PLANNER] Generated plan with ${result.object.steps.length} steps`, - ); - - this.validatePlan(result.object); - - return result.object as ExecutionPlan; - } - - private validatePlan(plan: ExecutionPlan): void { - const stepNumbers = new Set(plan.steps.map((s) => s.stepNumber)); - - for (const step of plan.steps) { - this.validateStepDependencies(step, stepNumbers); - } - - this.logger.log(`[PLANNER] Plan validation passed`); - } - - private validateStepDependencies( - step: { stepNumber: number; dependsOn?: number[] }, - validStepNumbers: Set, - ): void { - if (!step.dependsOn) { - return; - } - - this.checkForSelfDependency(step); - - for (const dependency of step.dependsOn) { - this.validateDependencyExists(dependency, validStepNumbers); - this.validateDependencyOrder(step.stepNumber, dependency); - } - } - - private checkForSelfDependency(step: { - stepNumber: number; - dependsOn?: number[]; - }): void { - if (step.dependsOn?.includes(step.stepNumber)) { - throw new Error(`Step ${step.stepNumber} cannot depend on itself`); - } - } - - private validateDependencyExists( - dependency: number, - validStepNumbers: Set, - ): void { - if (!validStepNumbers.has(dependency)) { - throw new Error(`Invalid dependency: step ${dependency} not found`); - } - } - - private validateDependencyOrder( - currentStepNumber: number, - dependency: number, - ): void { - if (dependency >= currentStepNumber) { - throw new Error( - `Step ${currentStepNumber} depends on future step ${dependency}`, - ); - } - } - - private getSmartModel(modelId: ModelId) { - if (modelId === DEFAULT_SMART_MODEL) { - return this.getDefaultSmartModel(); - } - - return this.getSpecificSmartModel(modelId); - } - - private getDefaultSmartModel() { - const registeredModel = - this.aiModelRegistryService.getDefaultPerformanceModel(); - - if (!registeredModel) { - throw new Error('No smart model available'); - } - - return registeredModel.model; - } - - private getSpecificSmartModel(modelId: ModelId) { - const registeredModel = this.aiModelRegistryService.getModel(modelId); - - if (!registeredModel) { - throw new Error(`Smart model "${modelId}" not available`); - } - - return registeredModel.model; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-strategy-decider.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-strategy-decider.service.ts deleted file mode 100644 index 63a4d5a26f..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/services/ai-chat-router-strategy-decider.service.ts +++ /dev/null @@ -1,198 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { - generateObject, - type LanguageModel, - type UIDataTypes, - type UIMessage, - type UITools, -} from 'ai'; -import { z } from 'zod'; - -import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const'; -import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { - DEFAULT_FAST_MODEL, - type ModelId, -} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; -import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; -import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; - -export type StrategyDecision = { - strategy: 'simple' | 'planned'; - agentName?: string; - toolHints?: { - relevantObjects?: string[]; - operations?: Array<'find' | 'create' | 'update' | 'delete'>; - }; -}; - -@Injectable() -export class AiChatRouterStrategyDeciderService { - private readonly logger = new Logger(AiChatRouterStrategyDeciderService.name); - - constructor( - private readonly aiModelRegistryService: AiModelRegistryService, - ) {} - - async decideStrategy({ - messages, - availableAgents, - agentDescriptions, - fastModel, - }: { - messages: UIMessage[]; - availableAgents: AgentEntity[]; - agentDescriptions: string; - fastModel: ModelId; - }): Promise { - if (availableAgents.length === 1) { - return this.createSingleAgentDecision(availableAgents[0]); - } - - const model = this.getFastModel(fastModel); - const agentNames = availableAgents.map((agent) => agent.name); - const conversationHistory = this.buildConversationHistory(messages); - const currentMessage = this.extractCurrentMessage(messages); - - const systemPrompt = AGENT_SYSTEM_PROMPTS.ROUTER(agentDescriptions); - const userPrompt = this.buildUserPrompt( - conversationHistory, - currentMessage, - ); - - const strategySchema = this.buildStrategySchema(agentNames); - const decision = await this.generateStrategyDecision( - model, - systemPrompt, - userPrompt, - strategySchema, - ); - - this.logger.log( - `[STRATEGY] Decision: ${JSON.stringify(decision, null, 2)}`, - ); - - return decision; - } - - private createSingleAgentDecision(agent: AgentEntity): StrategyDecision { - return { - strategy: 'simple', - agentName: agent.name, - }; - } - - private buildConversationHistory( - messages: UIMessage[], - ): string { - return messages - .slice(0, -1) - .map((message) => { - const textContent = - message.parts.find((part) => part.type === 'text')?.text || ''; - - return `${message.role}: ${textContent}`; - }) - .join('\n'); - } - - private extractCurrentMessage( - messages: UIMessage[], - ): string { - return ( - messages[messages.length - 1]?.parts.find((part) => part.type === 'text') - ?.text || '' - ); - } - - private buildStrategySchema(agentNames: string[]) { - return z.object({ - strategy: z - .enum(['simple', 'planned']) - .describe( - 'Routing strategy: "simple" for single agent, "planned" for multi-agent coordination', - ), - agentName: z - .enum([agentNames[0], ...agentNames.slice(1)]) - .optional() - .describe( - 'Agent name (REQUIRED if strategy is "simple", omit if "planned")', - ), - toolHints: z - .object({ - relevantObjects: z - .array(z.string()) - .optional() - .describe('Names of objects mentioned (e.g., "company", "person")'), - operations: z - .array(z.enum(['find', 'create', 'update', 'delete'])) - .optional() - .describe('Required database operations'), - }) - .optional() - .describe('Tool hints for simple strategy (optional)'), - }); - } - - private async generateStrategyDecision( - model: LanguageModel, - systemPrompt: string, - userPrompt: string, - schema: z.ZodTypeAny, - ): Promise { - const ROUTER_TEMPERATURE = 0.1; - - const result = await generateObject({ - model, - system: systemPrompt, - prompt: userPrompt, - schema, - temperature: ROUTER_TEMPERATURE, - experimental_telemetry: AI_TELEMETRY_CONFIG, - }); - - return result.object as StrategyDecision; - } - - private getFastModel(modelId: ModelId) { - if (modelId === DEFAULT_FAST_MODEL) { - return this.getDefaultFastModel(); - } - - return this.getSpecificFastModel(modelId); - } - - private getDefaultFastModel() { - const registeredModel = this.aiModelRegistryService.getDefaultSpeedModel(); - - if (!registeredModel) { - throw new Error('No fast model available'); - } - - return registeredModel.model; - } - - private getSpecificFastModel(modelId: ModelId) { - const registeredModel = this.aiModelRegistryService.getModel(modelId); - - if (!registeredModel) { - throw new Error(`Fast model "${modelId}" not available`); - } - - return registeredModel.model; - } - - private buildUserPrompt( - conversationHistory: string, - currentMessage: string, - ): string { - return `Conversation history: -${conversationHistory || 'No previous conversation'} - -Current user message: -${currentMessage} - -Which agent should handle this message?`; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface.ts deleted file mode 100644 index 898943391b..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; - -import { type ToolHints } from './tool-hints.interface'; - -export type PlanStep = { - stepNumber: number; - agentName: string; - task: string; - expectedOutput: string; - dependsOn?: number[]; -}; - -export type ExecutionPlan = { - steps: PlanStep[]; - reasoning: string; -}; - -export type RouterDebugInfo = { - availableAgents: Array<{ id: string; label: string }>; - routerModel: string; - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; -}; - -export type SimpleRouterResult = { - strategy: 'simple'; - agent: AgentEntity; - toolHints?: ToolHints; - debugInfo?: RouterDebugInfo; -}; - -export type PlannedRouterResult = { - strategy: 'planned'; - plan: ExecutionPlan; - debugInfo?: RouterDebugInfo; -}; - -export type UnifiedRouterResult = SimpleRouterResult | PlannedRouterResult; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface.ts deleted file mode 100644 index 848027128b..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type ToolOperation = 'find' | 'create' | 'update' | 'delete'; - -export interface ToolHints { - // Object names (singular or plural) that are relevant to the query - relevantObjects?: string[]; - // Specific CRUD operations needed for the query - operations?: ToolOperation[]; -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts index cce74ff789..dc9056c866 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/ai-chat.module.ts @@ -2,32 +2,30 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { TokenModule } from 'src/engine/core-modules/auth/token/token.module'; +import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module'; import { FileModule } from 'src/engine/core-modules/file/file.module'; import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module'; -import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module'; import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity'; import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module'; import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module'; -import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; -import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; -import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { AgentChatController } from './controllers/agent-chat.controller'; import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity'; import { AgentChatResolver } from './resolvers/agent-chat.resolver'; -import { AgentChatRoutingService } from './services/agent-chat-routing.service'; import { AgentChatStreamingService } from './services/agent-chat-streaming.service'; import { AgentChatService } from './services/agent-chat.service'; import { AgentTitleGenerationService } from './services/agent-title-generation.service'; -import { ChatToolsProviderService } from './services/chat-tools-provider.service'; +import { ChatExecutionService } from './services/chat-execution.service'; @Module({ imports: [ @@ -38,33 +36,27 @@ import { ChatToolsProviderService } from './services/chat-tools-provider.service ]), AiAgentModule, AiAgentExecutionModule, - AiChatRouterModule, ThrottlerModule, FeatureFlagModule, FileUploadModule, FileModule, PermissionsModule, WorkspaceCacheStorageModule, + WorkspaceCacheModule, + WorkspaceDomainsModule, + TwentyORMModule, TokenModule, UserWorkspaceModule, AiBillingModule, ToolProviderModule, - // WorkflowToolsModule provides workflow tools for chat context - WorkflowToolsModule, ], controllers: [AgentChatController], providers: [ AgentChatResolver, AgentChatService, AgentChatStreamingService, - AgentChatRoutingService, AgentTitleGenerationService, - ChatToolsProviderService, - // Provide WorkflowToolWorkspaceService via token for ToolProviderService - { - provide: WORKFLOW_TOOL_SERVICE_TOKEN, - useExisting: WorkflowToolWorkspaceService, - }, + ChatExecutionService, ], exports: [ AgentChatService, diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts new file mode 100644 index 0000000000..d0f8941961 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts @@ -0,0 +1,34 @@ +// System prompts for AI Chat (user-facing conversational interface) +export const CHAT_SYSTEM_PROMPTS = { + // Core chat behavior and tool strategy + BASE: `You are a helpful AI assistant integrated into Twenty CRM. + +Tool usage strategy: +- Chain multiple tools to solve complex tasks +- If a tool fails, try alternative approaches +- Use results from one tool to inform the next +- Don't give up after first failure - be persistent +- Validate assumptions before making changes + +Error recovery: +- Analyze error messages to understand what went wrong +- Adjust parameters or try different tools +- Only give up after exhausting reasonable alternatives + +Permissions: +- Only perform actions your role allows +- Explain limitations if you lack permissions`, + + // Response formatting and record references + RESPONSE_FORMAT: ` +Format responses with markdown for clarity (headings, lists, code blocks, tables). + +Record References - IMPORTANT: +- Tool responses include a "recordReferences" array with clickable links +- ONLY use record references that are returned by tools - NEVER make up IDs +- Copy the exact format from the tool response: [[record:objectName:recordId:displayName]] +- The recordId MUST be a real UUID (like "abc12345-1234-5678-abcd-123456789012") +- DO NOT create record references before calling the tool +- DO NOT use placeholder IDs like "rec-snowflake" or "rec-person-1" +- If a tool hasn't been called yet, don't reference records that don't exist`, +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts index fa258f8a7f..79fda5400d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/controllers/agent-chat.controller.ts @@ -12,7 +12,7 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter'; -import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard'; @@ -45,10 +45,10 @@ export class AgentChatController { this.agentStreamingService.streamAgentChat({ threadId: body.threadId, messages: body.messages, + recordIdsByObjectMetadataNameSingular: + body.recordIdsByObjectMetadataNameSingular ?? [], userWorkspaceId, workspace, - recordIdsByObjectMetadataNameSingular: - body.recordIdsByObjectMetadataNameSingular || [], response, }); } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts deleted file mode 100644 index 37c3ce78dc..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service.ts +++ /dev/null @@ -1,449 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai'; -import { type Response } from 'express'; -import { type ExtendedUIMessage } from 'twenty-shared/ai'; - -import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; -import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; -import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; -import { AgentExecutionService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service'; -import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service'; -import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; -import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; -import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util'; -import { AiChatRouterService } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service'; -import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; - -import { ChatToolsProviderService } from './chat-tools-provider.service'; - -export type TokenUsage = { - promptTokens: number; - completionTokens: number; - totalTokens: number; -}; - -export type MessagePersistenceCallbacks = { - saveSystemMessage?: (message: Omit) => Promise<{ - turnId: string; - }>; - saveUserMessage: ( - message: Omit, - turnId?: string, - ) => Promise<{ turnId: string }>; - saveAssistantMessage: ( - message: Omit, - turnId: string, - agentId: string, - ) => Promise; -}; - -export type StreamAgentExecutionOptions = { - userWorkspaceId: string; - workspace: WorkspaceEntity; - recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; - response: Response; - messages: ExtendedUIMessage[]; - persistenceCallbacks: MessagePersistenceCallbacks; -}; - -@Injectable() -export class AgentChatRoutingService { - private readonly logger = new Logger(AgentChatRoutingService.name); - - constructor( - private readonly agentExecutionService: AgentExecutionService, - private readonly agentPlanExecutorService: AgentPlanExecutorService, - private readonly aiChatRouterService: AiChatRouterService, - private readonly aiBillingService: AIBillingService, - private readonly chatToolsProviderService: ChatToolsProviderService, - private readonly agentActorContextService: AgentActorContextService, - ) {} - - async streamAgentExecution({ - userWorkspaceId, - workspace, - messages, - recordIdsByObjectMetadataNameSingular, - response, - persistenceCallbacks, - }: StreamAgentExecutionOptions) { - try { - const stream = createUIMessageStream({ - execute: async ({ writer }) => { - const startTime = Date.now(); - - writer.write({ - type: 'data-routing-status' as const, - id: 'routing-status', - data: { - text: 'Finding the best agent for your request...', - state: 'loading', - }, - }); - - const routingStart = Date.now(); - const includeDebugInfo = true; - const routeResult = await this.aiChatRouterService.routeMessage( - { - messages, - workspaceId: workspace.id, - fastModel: workspace.fastModel, - smartModel: workspace.smartModel, - }, - includeDebugInfo, - ); - - const routingTime = Date.now() - routingStart; - - const { debugInfo } = routeResult; - - let routingCostInCredits: number | undefined; - - if ( - debugInfo?.routerModel && - debugInfo?.promptTokens !== undefined && - debugInfo?.completionTokens !== undefined - ) { - try { - const routingCostInCents = - await this.aiBillingService.calculateCost( - debugInfo.routerModel as ModelId, - { - inputTokens: debugInfo.promptTokens, - outputTokens: debugInfo.completionTokens, - totalTokens: debugInfo.totalTokens || 0, - }, - ); - - routingCostInCredits = Math.round( - convertCentsToBillingCredits(routingCostInCents), - ); - } catch (error) { - this.logger.warn('Failed to calculate routing cost:', error); - } - } - - if (routeResult.strategy === 'planned') { - this.logger.log( - `Executing planned strategy with ${routeResult.plan.steps.length} steps`, - ); - this.logger.log( - `Plan steps: ${routeResult.plan.steps.map((s) => `${s.stepNumber}. ${s.agentName}: ${s.task}`).join('; ')}`, - ); - - writer.write({ - type: 'data-routing-status' as const, - id: 'routing-status', - data: { - text: `Executing ${routeResult.plan.steps.length}-step plan`, - state: 'routed', - debug: { - routingTimeMs: routingTime, - planReasoning: routeResult.plan.reasoning, - totalSteps: routeResult.plan.steps.length, - steps: routeResult.plan.steps.map((s) => ({ - stepNumber: s.stepNumber, - agent: s.agentName, - task: s.task, - })), - }, - }, - }); - - const planResult = await this.agentPlanExecutorService.executePlan({ - steps: routeResult.plan.steps, - reasoning: routeResult.plan.reasoning, - workspace, - userWorkspaceId, - recordIdsByObjectMetadataNameSingular, - writer, - onProgress: (progress) => { - if (progress.type === 'step-started') { - this.logger.log( - `Starting step ${progress.stepNumber}: ${progress.agentName} - ${progress.task}`, - ); - writer.write({ - type: 'data-routing-status' as const, - id: `step-${progress.stepNumber}`, - data: { - text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ${progress.agentName} → ${progress.task}`, - state: 'loading', - }, - }); - } else if (progress.type === 'step-completed') { - this.logger.log( - `Completed step ${progress.stepNumber}: ${progress.agentName}`, - ); - writer.write({ - type: 'data-routing-status' as const, - id: `step-${progress.stepNumber}`, - data: { - text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ✓ ${progress.agentName} completed`, - state: 'routed', - }, - }); - } - }, - }); - - const systemMessage = messages.find((msg) => msg.role === 'system'); - let turnId: string | undefined; - - if (systemMessage && persistenceCallbacks.saveSystemMessage) { - const savedSystemMessage = - await persistenceCallbacks.saveSystemMessage({ - role: AgentMessageRole.SYSTEM, - parts: systemMessage.parts, - }); - - turnId = savedSystemMessage.turnId; - } - - const userMessage = await persistenceCallbacks.saveUserMessage( - { - role: AgentMessageRole.USER, - parts: [ - { - type: 'text', - text: - messages[messages.length - 1].parts.find( - (part) => part.type === 'text', - )?.text ?? '', - }, - ], - }, - turnId, - ); - - await persistenceCallbacks.saveAssistantMessage( - { - role: AgentMessageRole.ASSISTANT, - parts: [ - { - type: 'text', - text: planResult.finalOutput, - }, - ], - }, - userMessage.turnId, - '', - ); - - return; - } - - const { agent, toolHints } = routeResult; - - this.logger.log(`Using agent ${agent.id} for message routing`); - - const agentExecutionStart = Date.now(); - - // Get permission-based tools for chat context (workflow, metadata, etc.) - // These tools are NOT available in workflow executor to prevent circular dependencies - const { roleId } = - await this.agentActorContextService.buildUserAndAgentActorContext( - userWorkspaceId, - workspace.id, - ); - - const roleIds = [roleId]; - - const chatTools = await this.chatToolsProviderService.getChatTools( - workspace.id, - roleIds, - toolHints, - ); - - const { - stream: result, - timings, - contextInfo, - } = await this.agentExecutionService.streamChatResponse({ - workspace, - agentId: agent.id, - userWorkspaceId, - messages, - recordIdsByObjectMetadataNameSingular, - toolHints, - additionalTools: chatTools, - }); - - const routedStatusPart = { - type: 'data-routing-status' as const, - id: 'routing-status', - data: { - text: `Routed to ${agent.label} agent`, - state: 'routed', - debug: { - routingTimeMs: routingTime, - contextBuildTimeMs: timings.contextBuildTimeMs, - agentExecutionStartTimeMs: Date.now() - startTime, - selectedAgentId: agent.id, - selectedAgentLabel: agent.label, - availableAgents: debugInfo?.availableAgents, - routerModel: debugInfo?.routerModel, - agentModel: agent.modelId, - context: contextInfo.contextString || undefined, - contextRecordCount: contextInfo.contextRecordCount, - contextSizeBytes: contextInfo.contextSizeBytes, - routingPromptTokens: debugInfo?.promptTokens, - routingCompletionTokens: debugInfo?.completionTokens, - routingTotalTokens: debugInfo?.totalTokens, - routingCostInCredits, - }, - }, - }; - - writer.write(routedStatusPart); - - writer.merge( - result.toUIMessageStream({ - onError: (error) => { - return error instanceof Error ? error.message : String(error); - }, - sendStart: false, - onFinish: async ({ responseMessage }) => { - if (responseMessage.parts.length === 0) { - return; - } - - const toolCallCount = responseMessage.parts.filter((part) => - part.type.startsWith('tool-'), - ).length; - - const tokenUsage = await this.extractTokenUsage(result.usage); - - const agentExecutionTime = Date.now() - agentExecutionStart; - - let agentCostInCredits: number | undefined; - let totalCostInCredits: number | undefined; - - if ( - agent.modelId && - tokenUsage && - tokenUsage.promptTokens > 0 && - tokenUsage.completionTokens > 0 - ) { - try { - const agentCostInCents = - await this.aiBillingService.calculateCost( - agent.modelId as ModelId, - { - inputTokens: tokenUsage.promptTokens, - outputTokens: tokenUsage.completionTokens, - totalTokens: tokenUsage.totalTokens, - }, - ); - - agentCostInCredits = Math.round( - convertCentsToBillingCredits(agentCostInCents), - ); - - totalCostInCredits = - (routingCostInCredits || 0) + agentCostInCredits; - } catch (error) { - this.logger.warn('Failed to calculate agent cost:', error); - } - } - - const updatedRoutedStatusPart = { - ...routedStatusPart, - data: { - ...routedStatusPart.data, - debug: { - ...routedStatusPart.data.debug, - agentExecutionTimeMs: agentExecutionTime, - toolCallCount, - toolCount: timings.toolCount, - agentContextBuildTimeMs: timings.contextBuildTimeMs, - toolGenerationTimeMs: timings.toolGenerationTimeMs, - aiRequestPrepTimeMs: timings.aiRequestPrepTimeMs, - ...(tokenUsage && { - agentPromptTokens: tokenUsage.promptTokens, - agentCompletionTokens: tokenUsage.completionTokens, - agentTotalTokens: tokenUsage.totalTokens, - }), - agentCostInCredits, - totalCostInCredits, - }, - }, - }; - - writer.write(updatedRoutedStatusPart); - - const userMessage = await persistenceCallbacks.saveUserMessage( - { - role: AgentMessageRole.USER, - parts: [ - { - type: 'text', - text: - messages[messages.length - 1].parts.find( - (part) => part.type === 'text', - )?.text ?? '', - }, - ], - }, - undefined, - ); - - await persistenceCallbacks.saveAssistantMessage( - { - ...responseMessage, - parts: [updatedRoutedStatusPart, ...responseMessage.parts], - }, - userMessage.turnId, - agent.id, - ); - }, - sendReasoning: true, - }), - ); - }, - }); - - pipeUIMessageStreamToResponse({ stream, response }); - } catch (error) { - this.logger.error( - 'Failed to stream agent execution:', - error instanceof Error ? error.message : String(error), - ); - response.end(); - } - } - - private async extractTokenUsage( - usagePromise: Promise, - ): Promise { - try { - const usage = await usagePromise; - - const usageWithTokens = usage as { - inputTokens?: number; - outputTokens?: number; - promptTokens?: number; - completionTokens?: number; - totalTokens?: number; - }; - - const tokenUsage = { - promptTokens: - usageWithTokens.inputTokens ?? usageWithTokens.promptTokens ?? 0, - completionTokens: - usageWithTokens.outputTokens ?? usageWithTokens.completionTokens ?? 0, - totalTokens: usageWithTokens.totalTokens ?? 0, - }; - - this.logger.log( - `Agent execution usage: ${tokenUsage.promptTokens} prompt + ${tokenUsage.completionTokens} completion = ${tokenUsage.totalTokens} total tokens`, - ); - - return tokenUsage; - } catch (error) { - this.logger.warn('Failed to get token usage:', error); - - return null; - } - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts index eee745f0e0..2ec5852935 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service.ts @@ -1,36 +1,41 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; +import { createUIMessageStream, pipeUIMessageStreamToResponse } from 'ai'; import { type Response } from 'express'; import { type ExtendedUIMessage } from 'twenty-shared/ai'; import { type Repository } from 'typeorm'; import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity'; import { AgentException, AgentExceptionCode, } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; -import { AgentChatRoutingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-routing.service'; -import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service'; + +import { AgentChatService } from './agent-chat.service'; +import { ChatExecutionService } from './chat-execution.service'; export type StreamAgentChatOptions = { threadId: string; userWorkspaceId: string; workspace: WorkspaceEntity; - recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; response: Response; messages: ExtendedUIMessage[]; + recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; }; @Injectable() export class AgentChatStreamingService { + private readonly logger = new Logger(AgentChatStreamingService.name); + constructor( @InjectRepository(AgentChatThreadEntity) private readonly threadRepository: Repository, private readonly agentChatService: AgentChatService, - private readonly agentChatRoutingService: AgentChatRoutingService, + private readonly chatExecutionService: ChatExecutionService, ) {} async streamAgentChat({ @@ -46,7 +51,6 @@ export class AgentChatStreamingService { id: threadId, userWorkspaceId, }, - relations: ['messages'], }); if (!thread) { @@ -56,39 +60,104 @@ export class AgentChatStreamingService { ); } - await this.agentChatRoutingService.streamAgentExecution({ - userWorkspaceId, - workspace, - messages, - recordIdsByObjectMetadataNameSingular, - response, - persistenceCallbacks: { - saveSystemMessage: async (message) => { - const savedMessage = await this.agentChatService.addMessage({ - threadId, - uiMessage: message, + try { + const uiStream = createUIMessageStream({ + execute: async ({ writer }) => { + const { stream } = await this.chatExecutionService.streamChat({ + workspace, + userWorkspaceId, + messages, + recordIdsByObjectMetadataNameSingular, }); - return { turnId: savedMessage.turnId }; - }, - saveUserMessage: async (message, turnId) => { - const savedMessage = await this.agentChatService.addMessage({ - threadId, - uiMessage: message, - turnId, + // Write initial status + writer.write({ + type: 'data-routing-status' as const, + id: 'execution-status', + data: { + text: 'Processing your request...', + state: 'loading', + }, }); - return { turnId: savedMessage.turnId }; + // Merge the AI stream + writer.merge( + stream.toUIMessageStream({ + onError: (error) => { + this.logger.error('Stream error:', error); + + return error instanceof Error ? error.message : String(error); + }, + sendStart: false, + onFinish: async ({ responseMessage }) => { + if (responseMessage.parts.length === 0) { + return; + } + + // Update status to completed + writer.write({ + type: 'data-routing-status' as const, + id: 'execution-status', + data: { + text: 'Completed', + state: 'routed', + }, + }); + + // Save messages to database + // Use thread.id from the validated thread object to ensure it's not null + const validThreadId = thread.id; + + if (!validThreadId) { + this.logger.error('Thread ID is unexpectedly null/undefined'); + + return; + } + + try { + const userMessage = await this.agentChatService.addMessage({ + threadId: validThreadId, + uiMessage: { + role: AgentMessageRole.USER, + parts: [ + { + type: 'text', + text: + messages[messages.length - 1].parts.find( + (part) => part.type === 'text', + )?.text ?? '', + }, + ], + }, + }); + + await this.agentChatService.addMessage({ + threadId: validThreadId, + uiMessage: responseMessage, + turnId: userMessage.turnId, + }); + } catch (saveError) { + this.logger.error( + 'Failed to save messages:', + saveError instanceof Error + ? saveError.message + : String(saveError), + ); + } + }, + sendReasoning: true, + }), + ); }, - saveAssistantMessage: async (message, turnId, agentId) => { - await this.agentChatService.addMessage({ - threadId, - uiMessage: message, - agentId, - turnId, - }); - }, - }, - }); + }); + + pipeUIMessageStreamToResponse({ stream: uiStream, response }); + } catch (error) { + this.logger.error( + 'Failed to stream chat:', + error instanceof Error ? error.message : String(error), + ); + response.end(); + } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts new file mode 100644 index 0000000000..9365aa441c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -0,0 +1,453 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { anthropic } from '@ai-sdk/anthropic'; +import { openai } from '@ai-sdk/openai'; +import { + convertToModelMessages, + stepCountIs, + streamText, + type ToolSet, + type UIDataTypes, + type UIMessage, + type UITools, +} from 'ai'; +import { AppPath } from 'twenty-shared/types'; +import { getAppPath } from 'twenty-shared/utils'; +import { In } from 'typeorm'; + +import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils'; +import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { + type ToolIndexEntry, + ToolRegistryService, +} from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { + AGENT_SEARCH_TOOL_NAME, + createAgentSearchTool, + createLoadToolsTool, + type DynamicToolStore, + LOAD_TOOLS_TOOL_NAME, +} from 'src/engine/core-modules/tool-provider/tools'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; +import { + AgentException, + AgentExceptionCode, +} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; +import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; +import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const'; +import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; +import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type'; +import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util'; +import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service'; +import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const'; +import { ModelProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; +import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const'; +import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +export type ChatExecutionOptions = { + workspace: WorkspaceEntity; + userWorkspaceId: string; + messages: UIMessage[]; + recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType; +}; + +export type ChatExecutionResult = { + stream: ReturnType; + preloadedTools: string[]; + initialAgents: string[]; +}; + +const INITIAL_AGENTS_LIMIT = 2; + +// Common tools to pre-load for quick access +const COMMON_PRELOAD_TOOLS = ['http_request', 'search_articles']; + +@Injectable() +export class ChatExecutionService { + private readonly logger = new Logger(ChatExecutionService.name); + + constructor( + private readonly toolRegistry: ToolRegistryService, + private readonly agentService: AgentService, + private readonly aiModelRegistryService: AiModelRegistryService, + private readonly aiBillingService: AIBillingService, + private readonly agentActorContextService: AgentActorContextService, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceDomainsService: WorkspaceDomainsService, + ) {} + + async streamChat({ + workspace, + userWorkspaceId, + messages, + recordIdsByObjectMetadataNameSingular, + }: ChatExecutionOptions): Promise { + const { actorContext, roleId } = + await this.agentActorContextService.buildUserAndAgentActorContext( + userWorkspaceId, + workspace.id, + ); + + const toolContext = { workspaceId: workspace.id, roleId, actorContext }; + + const lastUserMessage = this.getLastUserMessage(messages); + + let recordContext: string | undefined; + + if (recordIdsByObjectMetadataNameSingular.length > 0) { + recordContext = await this.buildContextFromRecords( + workspace, + recordIdsByObjectMetadataNameSingular, + userWorkspaceId, + ); + } + + const [toolCatalog, initialAgents] = await Promise.all([ + this.toolRegistry.buildToolIndex(workspace.id, roleId), + this.agentService.searchAgents(lastUserMessage, workspace.id, { + limit: INITIAL_AGENTS_LIMIT, + }), + ]); + + this.logger.log( + `Built tool catalog with ${toolCatalog.length} tools, ${initialAgents.length} agents`, + ); + + const preloadedTools = await this.toolRegistry.getToolsByName( + COMMON_PRELOAD_TOOLS, + toolContext, + ); + + const preloadedToolNames = Object.keys(preloadedTools); + + const dynamicToolStore: DynamicToolStore = { + loadedTools: new Set(preloadedToolNames), + }; + + const registeredModel = + this.aiModelRegistryService.getDefaultPerformanceModel(); + + const activeTools: ToolSet = { + ...preloadedTools, + ...this.getNativeWebSearchTool(registeredModel.provider), + [LOAD_TOOLS_TOOL_NAME]: createLoadToolsTool( + this.toolRegistry, + toolContext, + dynamicToolStore, + async (toolNames) => { + const newTools = await this.toolRegistry.getToolsByName( + toolNames, + toolContext, + ); + + Object.assign(activeTools, newTools); + this.logger.log(`Dynamically loaded tools: ${toolNames.join(', ')}`); + }, + ), + [AGENT_SEARCH_TOOL_NAME]: createAgentSearchTool((query, options) => + this.agentService.searchAgents(query, workspace.id, options), + ), + }; + + const systemPrompt = this.buildSystemPrompt( + toolCatalog, + initialAgents, + preloadedToolNames, + recordContext, + ); + + this.logger.log( + `Starting chat execution with model ${registeredModel.modelId}, ${Object.keys(activeTools).length} active tools`, + ); + + const stream = streamText({ + model: registeredModel.model, + system: systemPrompt, + messages: convertToModelMessages(messages), + tools: activeTools, + stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS), + experimental_telemetry: AI_TELEMETRY_CONFIG, + experimental_repairToolCall: async ({ + toolCall, + tools: toolsForRepair, + inputSchema, + error, + }) => { + return repairToolCall({ + toolCall, + tools: toolsForRepair, + inputSchema, + error, + model: registeredModel.model, + }); + }, + }); + + stream.usage + .then((usage) => { + this.aiBillingService.calculateAndBillUsage( + registeredModel.modelId, + usage, + workspace.id, + null, + ); + }) + .catch((error) => { + this.logger.error('Failed to bill usage:', error); + }); + + return { + stream, + preloadedTools: preloadedToolNames, + initialAgents: initialAgents.map((a) => a.name), + }; + } + + private async buildContextFromRecords( + workspace: WorkspaceEntity, + recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType, + userWorkspaceId: string, + ): Promise { + const { userWorkspaceRoleMap } = + await this.workspaceCacheService.getOrRecompute(workspace.id, [ + 'userWorkspaceRoleMap', + ]); + + const roleId = userWorkspaceRoleMap[userWorkspaceId]; + + if (!roleId) { + throw new AgentException( + 'Failed to retrieve user role.', + AgentExceptionCode.ROLE_NOT_FOUND, + ); + } + + const workspaceDataSource = + await this.twentyORMGlobalManager.getDataSourceForWorkspace({ + workspaceId: workspace.id, + }); + + const flatObjectMetadataMaps = + workspaceDataSource.internalContext.flatObjectMetadataMaps; + const flatFieldMetadataMaps = + workspaceDataSource.internalContext.flatFieldMetadataMaps; + const objectIdByNameSingular = + workspaceDataSource.internalContext.objectIdByNameSingular; + const objectMetadataPermissions = workspaceDataSource.permissionsPerRoleId; + + const contextObject = ( + await Promise.all( + recordIdsByObjectMetadataNameSingular.map( + async (recordsWithObjectMetadataNameSingular) => { + if (recordsWithObjectMetadataNameSingular.recordIds.length === 0) { + return []; + } + + const objectMetadataId = + objectIdByNameSingular[ + recordsWithObjectMetadataNameSingular.objectMetadataNameSingular + ]; + const objectMetadataMapItem = objectMetadataId + ? flatObjectMetadataMaps.byId[objectMetadataId] + : undefined; + + if (!objectMetadataMapItem) { + this.logger.warn( + `Object metadata not found for ${recordsWithObjectMetadataNameSingular.objectMetadataNameSingular}`, + ); + + return []; + } + + const repository = workspaceDataSource.getRepository( + recordsWithObjectMetadataNameSingular.objectMetadataNameSingular, + { unionOf: [roleId] }, + ); + + const restrictedFields = + objectMetadataPermissions?.[roleId]?.[objectMetadataMapItem.id] + ?.restrictedFields ?? {}; + + const hasRestrictedFields = Object.values(restrictedFields).some( + (field) => field.canRead === false, + ); + + const selectOptions = hasRestrictedFields + ? getAllSelectableColumnNames({ + restrictedFields, + objectMetadata: { + objectMetadataMapItem, + flatFieldMetadataMaps, + }, + }) + : undefined; + + return ( + await repository.find({ + ...(selectOptions && { select: selectOptions }), + where: { + id: In(recordsWithObjectMetadataNameSingular.recordIds), + }, + }) + ).map((record) => { + return { + ...record, + resourceUrl: this.workspaceDomainsService.buildWorkspaceURL({ + workspace, + pathname: getAppPath(AppPath.RecordShowPage, { + objectNameSingular: + recordsWithObjectMetadataNameSingular.objectMetadataNameSingular, + objectRecordId: record.id, + }), + }), + }; + }); + }, + ), + ) + ).flat(2); + + return JSON.stringify(contextObject); + } + + private getLastUserMessage( + messages: UIMessage[], + ): string { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + + if (message.role === 'user') { + const textPart = message.parts.find((part) => part.type === 'text'); + + if (textPart && 'text' in textPart) { + return textPart.text; + } + } + } + + return ''; + } + + private buildSystemPrompt( + toolCatalog: ToolIndexEntry[], + agents: AgentEntity[], + preloadedTools: string[], + recordContext?: string, + ): string { + const parts: string[] = [ + CHAT_SYSTEM_PROMPTS.BASE, + CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT, + ]; + + if (agents.length > 0) { + const skillsSection = agents + .map((agent) => `## ${agent.label} Expertise\n${agent.prompt}`) + .join('\n\n'); + + parts.push(`\nYou have the following expertise:\n\n${skillsSection}`); + } + + parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools)); + + if (recordContext) { + parts.push( + `\nCONTEXT (records the user is currently viewing):\n${recordContext}`, + ); + } + + return parts.join('\n'); + } + + private buildToolCatalogSection( + toolCatalog: ToolIndexEntry[], + preloadedTools: string[], + ): string { + const preloadedSet = new Set(preloadedTools); + + const toolsByCategory = new Map(); + + for (const tool of toolCatalog) { + const category = tool.category; + const existing = toolsByCategory.get(category) ?? []; + + existing.push(tool); + toolsByCategory.set(category, existing); + } + + const sections: string[] = []; + + sections.push(` +## Available Tools + +You have access to ${toolCatalog.length} tools plus native web search. Some are pre-loaded and ready to use immediately. +To use a tool that isn't pre-loaded, call \`${LOAD_TOOLS_TOOL_NAME}\` with the exact tool name(s) first. + +### Pre-loaded Tools (ready to use now) +- \`web_search\` ✓: Search the web for real-time information (ALWAYS use this for current data, news, research) +${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join('\n') : ''} + +### Tool Catalog by Category`); + + const categoryOrder = ['database', 'action', 'workflow', 'metadata']; + + for (const category of categoryOrder) { + const tools = toolsByCategory.get(category); + + if (!tools || tools.length === 0) { + continue; + } + + const categoryLabel = this.getCategoryLabel(category); + + sections.push(` +#### ${categoryLabel} (${tools.length} tools) +${tools + .map((t) => { + const status = preloadedSet.has(t.name) ? ' ✓' : ''; + + return `- \`${t.name}\`${status}: ${t.description}`; + }) + .join('\n')}`); + } + + sections.push(` +### How to Use Tools +1. **Web search** (\`web_search\`): Use for ANY request requiring current/real-time information from the internet +2. **Pre-loaded tools** (marked with ✓): Use directly +3. **Other tools**: First call \`${LOAD_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\`, then use the tool +4. **Agent expertise**: Call \`${AGENT_SEARCH_TOOL_NAME}\` to load specialized knowledge for workflows, etc.`); + + return sections.join('\n'); + } + + private getCategoryLabel(category: string): string { + switch (category) { + case 'database': + return 'Database Tools (CRUD operations)'; + case 'action': + return 'Action Tools (HTTP, Email, etc.)'; + case 'workflow': + return 'Workflow Tools (create/manage workflows)'; + case 'metadata': + return 'Metadata Tools (schema management)'; + default: + return category; + } + } + + private getNativeWebSearchTool(provider: ModelProvider): ToolSet { + switch (provider) { + case ModelProvider.ANTHROPIC: + return { web_search: anthropic.tools.webSearch_20250305() }; + case ModelProvider.OPENAI: + return { web_search: openai.tools.webSearch() }; + default: + // Other providers don't have native web search + return {}; + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts deleted file mode 100644 index 30e99c5bf9..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-tools-provider.service.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; - -import { type ToolSet } from 'ai'; - -import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; -import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; - -@Injectable() -export class ChatToolsProviderService { - private readonly logger = new Logger(ChatToolsProviderService.name); - - constructor(private readonly toolProvider: ToolProviderService) {} - - // Provides additional tools for the chat context (WORKFLOW and METADATA) - // These tools are NOT available in the workflow executor context to prevent circular dependencies - // Base tools (DATABASE_CRUD, ACTION) are provided by AgentToolGeneratorService - async getChatTools( - workspaceId: string, - roleIds: string[], - toolHints?: ToolHints, - ): Promise { - const tools = await this.toolProvider.getTools({ - workspaceId, - categories: [ToolCategory.WORKFLOW, ToolCategory.METADATA], - rolePermissionConfig: { intersectionOf: roleIds }, - toolHints, - wrapWithErrorContext: false, - }); - - this.logger.log( - `Generated ${Object.keys(tools).length} additional chat tools (workflow + metadata)`, - ); - - return tools; - } -} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service.ts index c03caf96f4..78e47e22f1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service.ts @@ -168,6 +168,7 @@ export class FlatAgentValidatorService { validationResult.errors.push( ...validateAgentRequiredProperties({ flatAgent: optimisticFlatAgent, + updatedProperties: partialFlatAgent, }), ); diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-agent-required-properties.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-agent-required-properties.util.ts index 1d4a5c7bba..9d4b4ef3a3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-agent-required-properties.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/utils/validate-agent-required-properties.util.ts @@ -1,18 +1,28 @@ -import { isNonEmptyString } from '@sniptt/guards'; import { msg, t } from '@lingui/core/macro'; +import { isNonEmptyString } from '@sniptt/guards'; import { AgentExceptionCode } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type'; import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/builders/types/failed-flat-entity-validation.type'; +type ValidateAgentRequiredPropertiesArgs = { + flatAgent: FlatAgent; + updatedProperties?: Partial; +}; + export const validateAgentRequiredProperties = ({ flatAgent, -}: { - flatAgent: FlatAgent; -}): FlatEntityValidationError[] => { + updatedProperties, +}: ValidateAgentRequiredPropertiesArgs): FlatEntityValidationError[] => { const errors: FlatEntityValidationError[] = []; - if (!isNonEmptyString(flatAgent.label)) { + // For updates, only validate properties that are being changed + const isUpdate = updatedProperties !== undefined; + const shouldValidateLabel = !isUpdate || 'label' in updatedProperties; + const shouldValidatePrompt = !isUpdate || 'prompt' in updatedProperties; + const shouldValidateModelId = !isUpdate || 'modelId' in updatedProperties; + + if (shouldValidateLabel && !isNonEmptyString(flatAgent.label)) { errors.push({ code: AgentExceptionCode.INVALID_AGENT_INPUT, message: t`Label cannot be empty`, @@ -20,7 +30,7 @@ export const validateAgentRequiredProperties = ({ }); } - if (!isNonEmptyString(flatAgent.prompt)) { + if (shouldValidatePrompt && !isNonEmptyString(flatAgent.prompt)) { errors.push({ code: AgentExceptionCode.INVALID_AGENT_INPUT, message: t`Prompt cannot be empty`, @@ -28,7 +38,7 @@ export const validateAgentRequiredProperties = ({ }); } - if (!isNonEmptyString(flatAgent.modelId)) { + if (shouldValidateModelId && !isNonEmptyString(flatAgent.modelId)) { errors.push({ code: AgentExceptionCode.INVALID_AGENT_INPUT, message: t`Model ID cannot be empty`, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts index c13a4ed3ba..e95ca52b68 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/__tests__/workflow-version-step-operations.workspace-service.spec.ts @@ -9,6 +9,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { type ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; import { @@ -28,6 +29,7 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { let objectMetadataRepository: jest.Mocked; let workflowCommonWorkspaceService: jest.Mocked; let aiAgentRoleService: jest.Mocked; + let workspaceCacheService: jest.Mocked; beforeEach(async () => { serverlessFunctionService = { @@ -65,6 +67,10 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { deleteAgentOnlyRoleIfUnused: jest.fn(), } as unknown as jest.Mocked; + workspaceCacheService = { + flush: jest.fn(), + } as unknown as jest.Mocked; + twentyORMGlobalManager = { getRepositoryForWorkspace: jest.fn(), } as unknown as jest.Mocked; @@ -104,6 +110,10 @@ describe('WorkflowVersionStepOperationsWorkspaceService', () => { provide: AiAgentRoleService, useValue: aiAgentRoleService, }, + { + provide: WorkspaceCacheService, + useValue: workspaceCacheService, + }, ], }).compile(); diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts index 94289a1826..ecbcaf36e8 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service.ts @@ -16,6 +16,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; import { WorkflowVersionStepException, WorkflowVersionStepExceptionCode, @@ -61,6 +62,7 @@ export class WorkflowVersionStepOperationsWorkspaceService { private readonly objectMetadataRepository: Repository, private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, private readonly aiAgentRoleService: AiAgentRoleService, + private readonly workspaceCacheService: WorkspaceCacheService, ) {} async runWorkflowVersionStepDeletionSideEffects({ @@ -369,7 +371,8 @@ export class WorkflowVersionStepOperationsWorkspaceService { label: 'Workflow Agent' + workflowVersion.workflowId.substring(0, 4), icon: 'IconRobot', description: '', - prompt: '', + prompt: + 'You are a helpful AI assistant. Complete the task based on the workflow context.', modelId: DEFAULT_SMART_MODEL, responseFormat: { type: 'text' }, workspaceId, @@ -383,6 +386,8 @@ export class WorkflowVersionStepOperationsWorkspaceService { ); } + await this.workspaceCacheService.flush(workspaceId, ['flatAgentMaps']); + return { builtStep: { ...baseStep, 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 9516b37595..b661d3d9f7 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 @@ -8,6 +8,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; import { WorkflowVersionStepCreationWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-creation.workspace-service'; @@ -23,6 +24,7 @@ import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workfl ServerlessFunctionModule, WorkflowCommonModule, AiAgentRoleModule, + WorkspaceCacheModule, NestjsQueryTypeOrmModule.forFeature([ ObjectMetadataEntity, AgentEntity, diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts index e4be08321a..97c7ada9cc 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts @@ -4,7 +4,6 @@ import { type ToolSet } from 'ai'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service'; -import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service'; @@ -127,7 +126,6 @@ export class WorkflowToolWorkspaceService { async generateRecordStepConfiguratorTools( workspaceId: string, rolePermissionConfig: RolePermissionConfig, - toolHints?: ToolHints, ): Promise { const workflowStepToolsFactory = createWorkflowStepToolsFactory( this.workflowStepToolsDeps, @@ -139,7 +137,6 @@ export class WorkflowToolWorkspaceService { rolePermissionConfig, }, [workflowStepToolsFactory], - toolHints, ); } } 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 deleted file mode 100644 index 981c7ed8fc..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { type ToolSet } from 'ai'; - -import { - type AgentToolTestContext, - createAgentToolTestModule, -} from './utils/agent-tool-test-utils'; - -// Helper to create mock tools based on object name -const createMockTools = ( - objectName: string, - options: { - canRead?: boolean; - canCreate?: boolean; - canUpdate?: boolean; - canDelete?: boolean; - }, -): ToolSet => { - const tools: Record = {}; - - if (options.canRead) { - tools[`find_${objectName}`] = { - description: `Find ${objectName} records`, - execute: jest.fn(), - }; - tools[`find_one_${objectName}`] = { - description: `Find one ${objectName} record`, - execute: jest.fn(), - }; - } - if (options.canCreate) { - tools[`create_${objectName}`] = { - description: `Create ${objectName} record`, - execute: jest.fn(), - }; - } - if (options.canUpdate) { - tools[`update_${objectName}`] = { - description: `Update ${objectName} record`, - execute: jest.fn(), - }; - } - if (options.canDelete) { - tools[`soft_delete_${objectName}`] = { - description: `Delete ${objectName} record`, - execute: jest.fn(), - }; - tools[`soft_delete_many_${objectName}`] = { - description: `Delete many ${objectName} records`, - execute: jest.fn(), - }; - } - - return tools as unknown as ToolSet; -}; - -describe('AgentToolGeneratorService Integration', () => { - let context: AgentToolTestContext; - - beforeEach(async () => { - context = await createAgentToolTestModule(); - }); - - afterEach(async () => { - await context.module.close(); - }); - - describe('Tool Generation', () => { - it('should generate complete tool set for agent with full permissions', async () => { - // Configure toolProviderService to return the expected tools - jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: true, - canUpdate: true, - canDelete: true, - }), - ); - - // Act - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - undefined, - [context.testRoleId], - ); - - // Assert - expect(tools).toBeDefined(); - expect(Object.keys(tools)).toHaveLength(6); - expect(Object.keys(tools)).toContain('create_testObject'); - expect(Object.keys(tools)).toContain('update_testObject'); - expect(Object.keys(tools)).toContain('find_testObject'); - expect(Object.keys(tools)).toContain('find_one_testObject'); - expect(Object.keys(tools)).toContain('soft_delete_testObject'); - expect(Object.keys(tools)).toContain('soft_delete_many_testObject'); - }); - - it('should generate read-only tools for agent with read permissions only', async () => { - // Configure toolProviderService to return read-only tools - jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: false, - canUpdate: false, - canDelete: false, - }), - ); - - // Act - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - undefined, - [context.testRoleId], - ); - - // Assert - expect(tools).toBeDefined(); - expect(Object.keys(tools)).toHaveLength(2); - expect(Object.keys(tools)).toContain('find_testObject'); - expect(Object.keys(tools)).toContain('find_one_testObject'); - expect(Object.keys(tools)).not.toContain('create_testObject'); - expect(Object.keys(tools)).not.toContain('update_testObject'); - }); - - it('should return no tool for agent without role', async () => { - // Configure toolProviderService to return empty tools when no role - jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue({}); - - // Act - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - ); - - // Assert - expect(Object.keys(tools)).toHaveLength(0); - }); - - it('should filter out workflow-run objects', async () => { - // Note: workflow objects are filtered out by ToolProviderService, - // so the mock returns tools for testObject (non-workflow) to simulate this behavior - jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue( - createMockTools('testObject', { - canRead: true, - canCreate: true, - canUpdate: true, - canDelete: true, - }), - ); - - // Act - const tools = await context.agentToolService.generateToolsForAgent( - context.testAgentId, - context.testWorkspaceId, - undefined, - [context.testRoleId], - ); - - // Assert - expect(Object.keys(tools)).toHaveLength(6); - }); - }); -}); diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts deleted file mode 100644 index 43843cd1f2..0000000000 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { type Repository } from 'typeorm'; - -import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; -import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service'; -import { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service'; -import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service'; -import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity'; -import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service'; -import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; -import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock'; - -export interface AgentToolTestContext { - module: TestingModule; - agentToolService: AgentToolGeneratorService; - agentService: AgentService; - objectMetadataService: ObjectMetadataService; - roleRepository: Repository; - workspaceCacheService: WorkspaceCacheService; - toolProviderService: ToolProviderService; - twentyORMGlobalManager: TwentyORMGlobalManager; - testAgent: AgentEntity & { roleId: string | null }; - testRole: RoleEntity; - testObjectMetadata: ObjectMetadataEntity; - testWorkspaceId: string; - testAgentId: string; - testRoleId: string; -} - -export const createAgentToolTestModule = - async (): Promise => { - const testWorkspaceId = 'test-workspace-id'; - const testAgentId = 'test-agent-id'; - const testRoleId = 'test-role-id'; - - const module = await Test.createTestingModule({ - providers: [ - AgentToolGeneratorService, - { - provide: AgentService, - useValue: { - findOneAgent: jest.fn(), - }, - }, - { - provide: getRepositoryToken(RoleEntity), - useValue: { - findOne: jest.fn(), - find: jest.fn(), - }, - }, - { - provide: getRepositoryToken(AgentEntity), - useValue: { - findOne: jest.fn(), - find: jest.fn(), - }, - }, - { - provide: ObjectMetadataService, - useValue: { - findManyWithinWorkspace: jest.fn(), - findOneWithinWorkspace: jest.fn(), - }, - }, - { - provide: TwentyORMGlobalManager, - useValue: { - getRepositoryForWorkspace: jest.fn(), - }, - }, - { - provide: WorkspaceCacheService, - useValue: { - getOrRecompute: jest.fn(), - }, - }, - { - provide: ToolProviderService, - useValue: { - getTools: jest.fn().mockResolvedValue({}), - }, - }, - { - provide: PermissionsService, - useValue: { - hasToolPermission: jest.fn(), - checkRolePermissions: jest.fn().mockReturnValue(true), - checkRolesPermissions: jest.fn().mockResolvedValue(true), - }, - }, - ], - }).compile(); - - const agentToolService = module.get( - AgentToolGeneratorService, - ); - const agentService = module.get(AgentService); - const objectMetadataService = module.get( - ObjectMetadataService, - ); - const roleRepository = module.get>( - getRepositoryToken(RoleEntity), - ); - const twentyORMGlobalManager = module.get( - TwentyORMGlobalManager, - ); - - const testAgent: AgentEntity & { roleId: string | null } = { - id: testAgentId, - name: 'test-agent', - label: 'Test Agent', - icon: 'IconTest', - isCustom: false, - applicationId: null, - application: {} as ApplicationEntity, - standardId: null, - deletedAt: null, - universalIdentifier: testAgentId, - description: 'Test agent for integration tests', - prompt: 'You are a test agent', - modelId: 'gpt-4o', - evaluationInputs: [], - responseFormat: { type: 'text' }, - workspaceId: testWorkspaceId, - workspace: {} as any, - roleId: testRoleId, - createdAt: new Date(), - updatedAt: new Date(), - modelConfiguration: {}, - }; - - const testRole: RoleEntity = { - id: testRoleId, - label: 'Test Role', - description: 'Test role for integration tests', - canUpdateAllSettings: false, - canReadAllObjectRecords: true, - canUpdateAllObjectRecords: true, - canSoftDeleteAllObjectRecords: true, - canDestroyAllObjectRecords: false, - workspaceId: testWorkspaceId, - createdAt: new Date(), - updatedAt: new Date(), - isEditable: true, - } as RoleEntity; - - const testObjectMetadata = getMockObjectMetadataEntity({ - id: 'test-object-id', - standardId: null, - dataSourceId: 'test-data-source-id', - nameSingular: 'testObject', - namePlural: 'testObjects', - labelSingular: 'Test Object', - labelPlural: 'Test Objects', - description: 'Test object for integration tests', - icon: 'IconTest', - targetTableName: 'test_objects', - isActive: true, - isSystem: false, - isCustom: false, - isRemote: false, - isAuditLogged: true, - isSearchable: false, - shortcut: '', - isLabelSyncedWithName: false, - workspaceId: testWorkspaceId, - createdAt: new Date(), - updatedAt: new Date(), - fields: [], - indexMetadatas: [], - targetRelationFields: [], - dataSource: {} as any, - objectPermissions: [], - fieldPermissions: [], - }); - - const workspaceCacheService = module.get( - WorkspaceCacheService, - ); - - const toolProviderService = - module.get(ToolProviderService); - - return { - module, - agentToolService, - agentService, - objectMetadataService, - roleRepository, - workspaceCacheService, - toolProviderService, - twentyORMGlobalManager, - testAgent, - testRole, - testObjectMetadata, - testWorkspaceId, - testAgentId, - testRoleId, - }; - };