From 3216b634a30236d6519cd3370cd4f61ba765b8aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Mon, 9 Feb 2026 14:26:02 +0100 Subject: [PATCH] feat: improve AI chat - system prompt, tool output, context window display (#17769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️ **AI-generated PR — not ready for review** ⚠️ cc @FelixMalfait --- ## Changes ### System prompt improvements - Explicit skill-before-tools workflow to prevent the model from calling tools without loading the matching skill first - Data efficiency guidance (default small limits, use filters) - Pluralized `load_skill` → `load_skills` for consistency with `load_tools` ### Token usage reduction - Output serialization layer: strips null/undefined/empty values from tool results - Lowered default `find_*` limit from 100 → 10, max from 1000 → 100 ### System object tool generation - System objects (calendar events, messages, etc.) now generate AI tools - Only workflow-related and favorite-related objects are excluded ### Context window display fix - **Bug**: UI compared cumulative tokens (sum of all turns) against single-request context window → showed 100% after a few turns - **Fix**: Track `conversationSize` (last step's `inputTokens`) which represents the actual conversation history size sent to the model - New `conversationSize` column on thread entity with migration ### Workspace AI instructions - Support for custom workspace-level AI instructions --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 74 ++- .../twenty-front/src/generated/graphql.ts | 21 +- .../components/AdvancedTextEditor.tsx | 7 +- .../ai/components/AIChatThreadGroup.tsx | 9 +- .../ai/components/ToolStepRenderer.tsx | 46 +- .../__stories__/AIChatMessage.stories.tsx | 2 + .../internal/AIChatContextUsageButton.tsx | 134 +++-- .../ai/graphql/queries/getChatThreads.ts | 1 + .../src/modules/ai/hooks/useAgentChat.ts | 14 +- .../src/modules/ai/hooks/useAgentChatData.ts | 9 +- .../modules/ai/states/agentChatUsageState.ts | 13 +- .../__tests__/groupThreadsByDate.test.ts | 1 + .../modules/ai/utils/getToolDisplayMessage.ts | 99 ++++ .../src/modules/ai/utils/getToolIcon.ts | 12 +- .../utils/getWebSearchToolDisplayMessage.ts | 56 -- .../modules/app/components/SettingsRoutes.tsx | 7 + .../auth/states/currentWorkspaceState.ts | 1 + .../graphql/fragments/userQueryFragment.ts | 1 + .../queries/getAISystemPromptPreview.ts | 14 + .../src/pages/settings/ai/SettingsAI.tsx | 40 +- .../pages/settings/ai/SettingsAIPrompts.tsx | 263 ++++++++++ packages/twenty-server/package.json | 1 + ...11652940-add-ai-additional-instructions.ts | 19 + ...00-addConversationSizeToAgentChatThread.ts | 19 + .../core-modules/billing/billing.resolver.ts | 16 +- .../billing-metered-product-usage.output.ts | 12 +- .../billing/utils/to-display-credits.util.ts | 9 + .../record-crud/record-crud.module.ts | 6 + .../services/create-many-records.service.ts | 109 ++++ .../services/create-record.service.ts | 2 +- .../services/update-many-records.service.ts | 97 ++++ .../services/update-record.service.ts | 2 +- .../direct-record-tools.factory.ts | 156 ------ .../types/create-many-records-params.type.ts | 14 + .../record-crud-execution-context.type.ts | 1 + .../types/update-many-records-params.type.ts | 14 + ...te-create-many-record-input-schema.util.ts | 26 + ...te-update-many-record-input-schema.util.ts | 31 ++ .../zod-schemas/delete-tool.zod-schema.ts | 7 + .../zod-schemas/find-tool.zod-schema.ts | 65 +-- .../zod-schemas/record-filter.zod-schema.ts | 67 +++ .../record-properties.zod-schema.ts | 8 +- .../soft-delete-tool.zod-schema.ts | 10 - .../constants/common-preload-tools.const.ts | 1 + .../interfaces/tool-provider.interface.ts | 14 +- .../__tests__/strip-empty-values.util.spec.ts | 153 ++++++ .../compact-tool-output.util.ts | 13 + .../strip-empty-values.util.ts | 32 ++ ...ap-tools-with-output-serialization.util.ts | 34 ++ .../providers/action-tool.provider.ts | 91 ++-- .../providers/dashboard-tool.provider.ts | 34 +- .../providers/database-tool.provider.ts | 240 +++++---- .../providers/logic-function-tool.provider.ts | 55 +- .../providers/metadata-tool.provider.ts | 30 +- .../providers/native-model-tool.provider.ts | 6 +- .../providers/view-tool.provider.ts | 55 +- .../providers/workflow-tool.provider.ts | 34 +- .../services/tool-executor.service.ts | 310 +++++++++++ .../services/tool-registry.service.ts | 492 ++++++++++-------- .../tool-provider/tool-provider.module.ts | 9 +- .../tool-provider/tools/execute-tool.tool.ts | 57 ++ .../core-modules/tool-provider/tools/index.ts | 22 +- .../tool-provider/tools/learn-tools.tool.ts | 74 +++ .../tool-provider/tools/load-skill.tool.ts | 2 +- .../tool-provider/tools/load-tools.tool.ts | 73 --- .../types/tool-descriptor.type.ts | 30 ++ .../utils/tool-set-to-descriptors.util.ts | 32 ++ .../twenty-config/config-variables.ts | 9 + .../workspace/dtos/update-workspace-input.ts | 5 + .../workspace/services/workspace.service.ts | 1 + .../workspace/workspace.entity.ts | 4 + .../services/agent-actor-context.service.ts | 16 + .../utils/is-favorite-related-object.util.ts | 14 + .../ai/ai-chat/ai-chat.module.ts | 2 + .../constants/chat-system-prompts.const.ts | 73 +-- .../ai/ai-chat/dtos/agent-chat-thread.dto.ts | 9 +- .../dtos/ai-system-prompt-preview.dto.ts | 22 + .../entities/agent-chat-thread.entity.ts | 3 + .../ai-chat/resolvers/agent-chat.resolver.ts | 46 +- .../services/agent-chat-streaming.service.ts | 58 ++- .../services/chat-execution.service.ts | 248 ++------- .../services/system-prompt-builder.service.ts | 333 ++++++++++++ .../constants/ai-models-types.const.ts | 3 + .../constants/ai-models.const.spec.ts | 4 +- .../ai/ai-models/constants/ai-models.const.ts | 2 + .../ai-models/constants/groq-models.const.ts | 15 + .../services/ai-model-registry.service.ts | 26 +- .../workspace-cache-storage.service.ts | 19 + .../builders/create-record-step.builder.ts | 94 ---- .../builders/delete-record-step.builder.ts | 87 ---- .../builders/find-records-step.builder.ts | 149 ------ .../factories/builders/step-builder.utils.ts | 52 -- .../builders/update-record-step.builder.ts | 101 ---- .../factories/workflow-step-tools.factory.ts | 75 --- .../workflow-tool.workspace-service.ts | 29 -- .../workflow-tools/workflow-tools.module.ts | 2 - .../src/ai/types/ExtendedUIMessage.ts | 2 + ...xtractSerializedRelationProperties.type.ts | 2 +- .../src/types/IsSerializedRelation.type.ts | 2 +- .../twenty-shared/src/types/SettingsPath.ts | 1 + ...erialized-relation-properties.type-test.ts | 2 - packages/twenty-shared/src/utils/index.ts | 1 + .../__tests__/camelToSnakeCase.test.ts | 25 + .../src/utils/strings/camelToSnakeCase.ts | 2 + yarn.lock | 13 + 105 files changed, 3245 insertions(+), 1714 deletions(-) create mode 100644 packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts delete mode 100644 packages/twenty-front/src/modules/ai/utils/getWebSearchToolDisplayMessage.ts create mode 100644 packages/twenty-front/src/modules/workspace/graphql/queries/getAISystemPromptPreview.ts create mode 100644 packages/twenty-front/src/pages/settings/ai/SettingsAIPrompts.tsx create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1770311652940-add-ai-additional-instructions.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1770400000000-addConversationSizeToAgentChatThread.ts create mode 100644 packages/twenty-server/src/engine/core-modules/billing/utils/to-display-credits.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/create-many-records.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/update-many-records.service.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/create-many-records-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/update-many-records-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/constants/common-preload-tools.const.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/__tests__/strip-empty-values.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts delete mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-tools.tool.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/groq-models.const.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts create mode 100644 packages/twenty-shared/src/utils/strings/__tests__/camelToSnakeCase.test.ts create mode 100644 packages/twenty-shared/src/utils/strings/camelToSnakeCase.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b48c8866b2f..1901f460762 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -21,6 +21,19 @@ export type Scalars = { Upload: any; }; +export type AiSystemPromptPreview = { + __typename?: 'AISystemPromptPreview'; + estimatedTokenCount: Scalars['Int']; + sections: Array; +}; + +export type AiSystemPromptSection = { + __typename?: 'AISystemPromptSection'; + content: Scalars['String']; + estimatedTokenCount: Scalars['Int']; + title: Scalars['String']; +}; + export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; @@ -76,12 +89,13 @@ export type Agent = { export type AgentChatThread = { __typename?: 'AgentChatThread'; contextWindowTokens?: Maybe; + conversationSize: Scalars['Int']; createdAt: Scalars['DateTime']; id: Scalars['UUID']; title?: Maybe; - totalInputCredits: Scalars['Int']; + totalInputCredits: Scalars['Float']; totalInputTokens: Scalars['Int']; - totalOutputCredits: Scalars['Int']; + totalOutputCredits: Scalars['Float']; totalOutputTokens: Scalars['Int']; updatedAt: Scalars['DateTime']; }; @@ -2067,6 +2081,7 @@ export enum MessageChannelVisibility { export enum ModelProvider { ANTHROPIC = 'ANTHROPIC', + GROQ = 'GROQ', NONE = 'NONE', OPENAI = 'OPENAI', OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE', @@ -3653,6 +3668,7 @@ export type Query = { findWorkspaceInvitations: Array; frontComponent?: Maybe; frontComponents: Array; + getAISystemPromptPreview: AiSystemPromptPreview; getAddressDetails: PlaceDetailsResult; getApprovedAccessDomains: Array; getAutoCompleteAddress: Array; @@ -4943,6 +4959,7 @@ export type UpdateWorkflowVersionStepInput = { }; export type UpdateWorkspaceInput = { + aiAdditionalInstructions?: InputMaybe; allowImpersonation?: InputMaybe; customDomain?: InputMaybe; defaultRoleId?: InputMaybe; @@ -5330,6 +5347,7 @@ export type WorkflowVersionStepChanges = { export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; + aiAdditionalInstructions?: Maybe; allowImpersonation: Scalars['Boolean']; billingEntitlements: Array; billingSubscriptions: Array; @@ -5628,7 +5646,7 @@ export type GetChatMessagesQuery = { __typename?: 'Query', chatMessages: Array<{ export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> }; +export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, conversationSize: number, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> }; export type GetToolIndexQueryVariables = Exact<{ [key: string]: never; }>; @@ -6598,7 +6616,7 @@ export type BillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscri export type CurrentBillingSubscriptionFragmentFragment = { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } }; export type WorkspaceUrlsFragmentFragment = { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }; @@ -6617,7 +6635,7 @@ export type DeleteUserWorkspaceMutation = { __typename?: 'Mutation', deleteUserF export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: string, firstName: string, lastName: string, email: string, hasPassword: boolean, canAccessFullAdminPanel: boolean, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars?: any | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: string, colorScheme: string, avatarUrl?: string | null, locale?: string | null, userEmail: string, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, calendarStartDay?: number | null, numberFormat?: WorkspaceMemberNumberFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, deletedWorkspaceMembers?: Array<{ __typename?: 'DeletedWorkspaceMember', id: string, avatarUrl?: string | null, userEmail: string, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, currentUserWorkspace?: { __typename?: 'UserWorkspace', id: string, permissionFlags?: Array | null, objectsPermissions?: Array<{ __typename?: 'ObjectPermission', objectMetadataId: string, canReadObjectRecords?: boolean | null, canUpdateObjectRecords?: boolean | null, canSoftDeleteObjectRecords?: boolean | null, canDestroyObjectRecords?: boolean | null, restrictedFields?: any | null, rowLevelPermissionPredicates?: Array<{ __typename?: 'RowLevelPermissionPredicate', id: string, fieldMetadataId: string, objectMetadataId: string, operand: RowLevelPermissionPredicateOperand, subFieldName?: string | null, workspaceMemberFieldMetadataId?: string | null, workspaceMemberSubFieldName?: string | null, rowLevelPermissionPredicateGroupId?: string | null, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, value?: any | null }> | null, rowLevelPermissionPredicateGroups?: Array<{ __typename?: 'RowLevelPermissionPredicateGroup', id: string, parentRowLevelPermissionPredicateGroupId?: string | null, logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator, positionInRowLevelPermissionPredicateGroup?: number | null, roleId: string, objectMetadataId: string }> | null }> | null, twoFactorAuthenticationMethodSummary?: Array<{ __typename?: 'TwoFactorAuthenticationMethodDTO', twoFactorAuthenticationMethodId: string, status: string, strategy: string }> | null } | null, currentWorkspace?: { __typename?: 'Workspace', id: string, displayName?: string | null, logo?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, isGoogleAuthEnabled: boolean, isMicrosoftAuthEnabled: boolean, isPasswordAuthEnabled: boolean, isGoogleAuthBypassEnabled: boolean, isMicrosoftAuthBypassEnabled: boolean, isPasswordAuthBypassEnabled: boolean, subdomain: string, hasValidEnterpriseKey: boolean, isCustomDomainEnabled: boolean, metadataVersion: number, workspaceMembersCount?: number | null, fastModel: string, smartModel: string, aiAdditionalInstructions?: string | null, isTwoFactorAuthenticationEnforced: boolean, trashRetentionDays: number, eventLogRetentionDays: number, editableProfileFields?: Array | null, workspaceCustomApplication?: { __typename?: 'Application', id: string } | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, featureFlags?: Array<{ __typename?: 'FeatureFlagDTO', key: FeatureFlagKey, value: boolean }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, interval?: SubscriptionInterval | null, metadata: any, currentPeriodEnd?: string | null, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }>, billingSubscriptionItems?: Array<{ __typename?: 'BillingSubscriptionItemDTO', id: string, hasReachedCurrentPeriodCap: boolean, quantity?: number | null, stripePriceId: string, billingProduct: { __typename?: 'BillingLicensedProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } | { __typename?: 'BillingMeteredProduct', name: string, description: string, images?: Array | null, metadata: { __typename?: 'BillingProductMetadata', productKey: BillingProductKey, planKey: BillingPlanKey, priceUsageBased: BillingUsageType } } }> | null } | null, billingSubscriptions: Array<{ __typename?: 'BillingSubscription', id: string, status: SubscriptionStatus, metadata: any, phases: Array<{ __typename?: 'BillingSubscriptionSchedulePhase', start_date: number, end_date: number, items: Array<{ __typename?: 'BillingSubscriptionSchedulePhaseItem', price: string, quantity?: number | null }> }> }>, billingEntitlements: Array<{ __typename?: 'BillingEntitlement', key: BillingEntitlementKey, value: boolean }>, defaultRole?: { __typename?: 'Role', id: string, label: string, description?: string | null, icon?: string | null, canUpdateAllSettings: boolean, canAccessAllTools: boolean, isEditable: boolean, canReadAllObjectRecords: boolean, canUpdateAllObjectRecords: boolean, canSoftDeleteAllObjectRecords: boolean, canDestroyAllObjectRecords: boolean, canBeAssignedToUsers: boolean, canBeAssignedToAgents: boolean, canBeAssignedToApiKeys: boolean } | null } | null, availableWorkspaces: { __typename?: 'AvailableWorkspaces', availableWorkspacesForSignIn: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }>, availableWorkspacesForSignUp: Array<{ __typename?: 'AvailableWorkspace', id: string, displayName?: string | null, loginToken?: string | null, inviteHash?: string | null, personalInviteToken?: string | null, logo?: string | null, workspaceUrls: { __typename?: 'WorkspaceUrls', subdomainUrl: string, customUrl?: string | null }, sso: Array<{ __typename?: 'SSOConnection', type: IdentityProviderType, id: string, issuer: string, name: string, status: SsoIdentityProviderStatus }> }> } } }; export type ViewFieldFragmentFragment = { __typename?: 'CoreViewField', id: string, fieldMetadataId: string, viewId: string, isVisible: boolean, position: number, size: number, aggregateOperation?: AggregateOperations | null, createdAt: string, updatedAt: string, deletedAt?: string | null }; @@ -7089,6 +7107,11 @@ export type CheckCustomDomainValidRecordsMutationVariables = Exact<{ [key: strin export type CheckCustomDomainValidRecordsMutation = { __typename?: 'Mutation', checkCustomDomainValidRecords?: { __typename?: 'DomainValidRecords', id: string, domain: string, records: Array<{ __typename?: 'DomainRecord', type: string, key: string, value: string, validationType: string, status: string }> } | null }; +export type GetAiSystemPromptPreviewQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetAiSystemPromptPreviewQuery = { __typename?: 'Query', getAISystemPromptPreview: { __typename?: 'AISystemPromptPreview', estimatedTokenCount: number, sections: Array<{ __typename?: 'AISystemPromptSection', title: string, content: string, estimatedTokenCount: number }> } }; + export type GetWorkspaceFromInviteHashQueryVariables = Exact<{ inviteHash: Scalars['String']; }>; @@ -7683,6 +7706,7 @@ export const UserQueryFragmentFragmentDoc = gql` } fastModel smartModel + aiAdditionalInstructions isTwoFactorAuthenticationEnforced trashRetentionDays eventLogRetentionDays @@ -8534,6 +8558,7 @@ export const GetChatThreadsDocument = gql` totalInputTokens totalOutputTokens contextWindowTokens + conversationSize totalInputCredits totalOutputCredits createdAt @@ -15914,6 +15939,45 @@ export function useCheckCustomDomainValidRecordsMutation(baseOptions?: Apollo.Mu export type CheckCustomDomainValidRecordsMutationHookResult = ReturnType; export type CheckCustomDomainValidRecordsMutationResult = Apollo.MutationResult; export type CheckCustomDomainValidRecordsMutationOptions = Apollo.BaseMutationOptions; +export const GetAiSystemPromptPreviewDocument = gql` + query GetAISystemPromptPreview { + getAISystemPromptPreview { + sections { + title + content + estimatedTokenCount + } + estimatedTokenCount + } +} + `; + +/** + * __useGetAiSystemPromptPreviewQuery__ + * + * To run a query within a React component, call `useGetAiSystemPromptPreviewQuery` and pass it any options that fit your needs. + * When your component renders, `useGetAiSystemPromptPreviewQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useGetAiSystemPromptPreviewQuery({ + * variables: { + * }, + * }); + */ +export function useGetAiSystemPromptPreviewQuery(baseOptions?: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetAiSystemPromptPreviewDocument, options); + } +export function useGetAiSystemPromptPreviewLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetAiSystemPromptPreviewDocument, options); + } +export type GetAiSystemPromptPreviewQueryHookResult = ReturnType; +export type GetAiSystemPromptPreviewLazyQueryHookResult = ReturnType; +export type GetAiSystemPromptPreviewQueryResult = Apollo.QueryResult; export const GetWorkspaceFromInviteHashDocument = gql` query GetWorkspaceFromInviteHash($inviteHash: String!) { findWorkspaceFromInviteHash(inviteHash: $inviteHash) { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index ee8a3d12cc5..c319fac5475 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -21,6 +21,19 @@ export type Scalars = { Upload: any; }; +export type AiSystemPromptPreview = { + __typename?: 'AISystemPromptPreview'; + estimatedTokenCount: Scalars['Int']; + sections: Array; +}; + +export type AiSystemPromptSection = { + __typename?: 'AISystemPromptSection'; + content: Scalars['String']; + estimatedTokenCount: Scalars['Int']; + title: Scalars['String']; +}; + export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; @@ -76,12 +89,13 @@ export type Agent = { export type AgentChatThread = { __typename?: 'AgentChatThread'; contextWindowTokens?: Maybe; + conversationSize: Scalars['Int']; createdAt: Scalars['DateTime']; id: Scalars['UUID']; title?: Maybe; - totalInputCredits: Scalars['Int']; + totalInputCredits: Scalars['Float']; totalInputTokens: Scalars['Int']; - totalOutputCredits: Scalars['Int']; + totalOutputCredits: Scalars['Float']; totalOutputTokens: Scalars['Int']; updatedAt: Scalars['DateTime']; }; @@ -2039,6 +2053,7 @@ export enum MessageChannelVisibility { export enum ModelProvider { ANTHROPIC = 'ANTHROPIC', + GROQ = 'GROQ', NONE = 'NONE', OPENAI = 'OPENAI', OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE', @@ -4759,6 +4774,7 @@ export type UpdateWorkflowVersionStepInput = { }; export type UpdateWorkspaceInput = { + aiAdditionalInstructions?: InputMaybe; allowImpersonation?: InputMaybe; customDomain?: InputMaybe; defaultRoleId?: InputMaybe; @@ -5146,6 +5162,7 @@ export type WorkflowVersionStepChanges = { export type Workspace = { __typename?: 'Workspace'; activationStatus: WorkspaceActivationStatus; + aiAdditionalInstructions?: Maybe; allowImpersonation: Scalars['Boolean']; billingEntitlements: Array; billingSubscriptions: Array; diff --git a/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx index ec9fab55bf8..f2bfe2259ab 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx @@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{ color: ${({ theme, readonly }) => readonly ? theme.font.color.light : theme.font.color.primary}; font-family: ${({ theme }) => theme.font.family}; + font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.regular}; border: none !important; @@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{ } h1 { - font-size: 32px; + font-size: 1.5em; } h2 { - font-size: 24px; + font-size: 1.3em; } h3 { - font-size: 16px; + font-size: 1.1em; } li { diff --git a/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx index d0c533bee3b..31ecd727401 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatThreadGroup.tsx @@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({ const handleThreadClick = (thread: AgentChatThread) => { setCurrentAIChatThread(thread.id); - const totalTokens = thread.totalInputTokens + thread.totalOutputTokens; const hasUsageData = - totalTokens > 0 && isDefined(thread.contextWindowTokens); + (thread.conversationSize ?? 0) > 0 && + isDefined(thread.contextWindowTokens); setAgentChatUsage( hasUsageData ? { + lastMessage: null, + conversationSize: thread.conversationSize ?? 0, + contextWindowTokens: thread.contextWindowTokens ?? 0, inputTokens: thread.totalInputTokens, outputTokens: thread.totalOutputTokens, - totalTokens, - contextWindowTokens: thread.contextWindowTokens ?? 0, inputCredits: thread.totalInputCredits, outputCredits: thread.totalOutputCredits, } diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index 084d9f608f6..fb3d37e006a 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout'; import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay'; import { ShimmeringText } from '@/ai/components/ShimmeringText'; import { getToolIcon } from '@/ai/utils/getToolIcon'; -import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage'; +import { + getToolDisplayMessage, + resolveToolInput, +} from '@/ai/utils/getToolDisplayMessage'; import { useLingui } from '@lingui/react/macro'; import { type ToolUIPart } from 'ai'; import { isDefined } from 'twenty-shared/utils'; @@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { const [activeTab, setActiveTab] = useState('output'); const { input, output, type, errorText } = toolPart; - const toolName = type.split('-')[1]; + const rawToolName = type.split('-')[1]; - const toolInput = - isDefined(input) && typeof input === 'object' && 'input' in input - ? input.input - : input; + const { resolvedInput: toolInput, resolvedToolName: toolName } = + resolveToolInput(input, rawToolName); const hasError = isDefined(errorText); const isExpandable = isDefined(output) || hasError; @@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { - {getToolDisplayMessage(input, toolName, false)} + {getToolDisplayMessage(input, rawToolName, false)} @@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => { ); } + // For execute_tool, the actual result is nested inside output.result + const unwrappedOutput = + rawToolName === 'execute_tool' && + isDefined(output) && + typeof output === 'object' && + 'result' in output + ? (output as { result: unknown }).result + : output; + const displayMessage = hasError ? t`Tool execution failed` - : output && - typeof output === 'object' && - 'message' in output && - typeof output.message === 'string' - ? output.message - : getToolDisplayMessage(input, toolName, true); + : rawToolName === 'learn_tools' || rawToolName === 'execute_tool' + ? getToolDisplayMessage(input, rawToolName, true) + : unwrappedOutput && + typeof unwrappedOutput === 'object' && + 'message' in unwrappedOutput && + typeof unwrappedOutput.message === 'string' + ? unwrappedOutput.message + : getToolDisplayMessage(input, rawToolName, true); const result = - output && typeof output === 'object' && 'result' in output - ? (output as { result: string }).result - : output; + unwrappedOutput && + typeof unwrappedOutput === 'object' && + 'result' in unwrappedOutput + ? (unwrappedOutput as { result: string }).result + : unwrappedOutput; const ToolIcon = getToolIcon(toolName); diff --git a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx index f5888188429..10f25973853 100644 --- a/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx +++ b/packages/twenty-front/src/modules/ai/components/__stories__/AIChatMessage.stories.tsx @@ -82,8 +82,10 @@ print("Chart saved successfully!")`, usage: { inputTokens: 1250, outputTokens: 890, + cachedInputTokens: 0, inputCredits: 12, outputCredits: 8, + conversationSize: 1250, }, }, }; diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx index ad499efdf52..2070f6f603c 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx @@ -1,12 +1,17 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { t } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; import { useState } from 'react'; import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-shared/utils'; import { ProgressBar } from 'twenty-ui/feedback'; import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing'; -import { agentChatUsageState } from '@/ai/states/agentChatUsageState'; +import { + agentChatUsageState, + type AgentChatLastMessageUsage, +} from '@/ai/states/agentChatUsageState'; const StyledContainer = styled.div` position: relative; @@ -41,14 +46,14 @@ const StyledHoverCard = styled.div` border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.md}; box-shadow: ${({ theme }) => theme.boxShadow.strong}; - min-width: 240px; + min-width: 280px; position: absolute; right: 0; bottom: calc(100% + 8px); z-index: ${({ theme }) => theme.lastLayerZIndex}; `; -const StyledHeader = styled.div` +const StyledSection = styled.div` display: flex; flex-direction: column; gap: ${({ theme }) => theme.spacing(2)}; @@ -61,14 +66,6 @@ const StyledRow = styled.div` justify-content: space-between; `; -const StyledBody = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing(2)}; - padding: ${({ theme }) => theme.spacing(3)}; - padding-top: 0; -`; - const StyledLabel = styled.span` color: ${({ theme }) => theme.font.color.secondary}; font-size: ${({ theme }) => theme.font.size.sm}; @@ -79,15 +76,16 @@ const StyledValue = styled.span` font-size: ${({ theme }) => theme.font.size.sm}; `; -const StyledFooter = styled.div` - align-items: center; - background: ${({ theme }) => theme.background.secondary}; +const StyledSectionTitle = styled.span` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const StyledDivider = styled.div` border-top: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: 0 0 ${({ theme }) => theme.border.radius.md} - ${({ theme }) => theme.border.radius.md}; - display: flex; - justify-content: space-between; - padding: ${({ theme }) => theme.spacing(3)}; `; const formatTokenCount = (count: number): string => { @@ -103,6 +101,31 @@ const formatTokenCount = (count: number): string => { return count.toString(); }; +const formatCredits = (credits: number): string => { + // Credits are already in display units from the API (internal / 1000) + // Show up to 1 decimal for fractional values, none for whole numbers + if (Number.isInteger(credits)) { + return credits.toLocaleString(); + } + + return credits.toLocaleString(undefined, { + minimumFractionDigits: 0, + maximumFractionDigits: 1, + }); +}; + +const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => { + if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) { + return ''; + } + + const cachedPercent = Math.round( + (lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100, + ); + + return ` (${t`${cachedPercent}% cached`})`; +}; + export const AIChatContextUsageButton = () => { const { t } = useLingui(); const theme = useTheme(); @@ -121,14 +144,14 @@ export const AIChatContextUsageButton = () => { } const percentage = Math.min( - (agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100, + (agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) * + 100, 100, ); const formattedPercentage = percentage.toFixed(1); const totalCredits = agentChatUsage.inputCredits + agentChatUsage.outputCredits; - const inputCredits = agentChatUsage.inputCredits.toLocaleString(); - const outputCredits = agentChatUsage.outputCredits.toLocaleString(); + const lastMessage = agentChatUsage.lastMessage; return ( { {isHovered && ( - + {formattedPercentage}% - {formatTokenCount(agentChatUsage.totalTokens)} /{' '} - {formatTokenCount(agentChatUsage.contextWindowTokens)} + {formatTokenCount(agentChatUsage.conversationSize)} /{' '} + {formatTokenCount(agentChatUsage.contextWindowTokens)}{' '} + {t`tokens`} { backgroundColor={theme.background.quaternary} withBorderRadius /> - + - + {isDefined(lastMessage) && ( + <> + + + {t`Last message`} + + {t`Input tokens`} + + {formatTokenCount(lastMessage.inputTokens)} + {getCachedLabel(lastMessage)} + + + + {t`Output tokens`} + + {formatTokenCount(lastMessage.outputTokens)} + + + + {t`Cost`} + + {formatCredits( + lastMessage.inputCredits + lastMessage.outputCredits, + )}{' '} + {t`credits`} + + + + + )} + + + + {t`Conversation`} - {t`Input`} + {t`Input tokens`} - {formatTokenCount(agentChatUsage.inputTokens)} •{' '} - {t`${inputCredits} credits`} + {formatTokenCount(agentChatUsage.inputTokens)} - {t`Output`} + {t`Output tokens`} - {formatTokenCount(agentChatUsage.outputTokens)} •{' '} - {t`${outputCredits} credits`} + {formatTokenCount(agentChatUsage.outputTokens)} - - - - {t`Total credits`} - {totalCredits.toLocaleString()} - + + {t`Total cost`} + + {formatCredits(totalCredits)} {t`credits`} + + + )} diff --git a/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts b/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts index 5f5c6a16984..3b9d633c0a2 100644 --- a/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts +++ b/packages/twenty-front/src/modules/ai/graphql/queries/getChatThreads.ts @@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql` totalInputTokens totalOutputTokens contextWindowTokens + conversationSize totalInputCredits totalOutputCredits createdAt diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts index 197dba7a657..942a7fbae36 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChat.ts @@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { type UsageMetadata = { inputTokens: number; outputTokens: number; + cachedInputTokens: number; inputCredits: number; outputCredits: number; + conversationSize: number; }; type ModelMetadata = { contextWindowTokens: number; @@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => { if (isDefined(usage) && isDefined(model)) { setAgentChatUsage((prev) => ({ + lastMessage: { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cachedInputTokens: usage.cachedInputTokens, + inputCredits: usage.inputCredits, + outputCredits: usage.outputCredits, + }, + conversationSize: usage.conversationSize, + contextWindowTokens: model.contextWindowTokens, inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens, outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens, - totalTokens: - (prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens, - contextWindowTokens: model.contextWindowTokens, inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits, outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits, })); diff --git a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts index 046e8154d49..dee5e67582c 100644 --- a/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts +++ b/packages/twenty-front/src/modules/ai/hooks/useAgentChatData.ts @@ -23,16 +23,17 @@ const setUsageFromThread = ( thread: AgentChatThread, setAgentChatUsage: SetterOrUpdater, ) => { - const totalTokens = thread.totalInputTokens + thread.totalOutputTokens; - const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens); + const hasUsageData = + (thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens); setAgentChatUsage( hasUsageData ? { + lastMessage: null, + conversationSize: thread.conversationSize ?? 0, + contextWindowTokens: thread.contextWindowTokens ?? 0, inputTokens: thread.totalInputTokens, outputTokens: thread.totalOutputTokens, - totalTokens, - contextWindowTokens: thread.contextWindowTokens ?? 0, inputCredits: thread.totalInputCredits, outputCredits: thread.totalOutputCredits, } diff --git a/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts b/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts index acc8370b759..5c2462659f4 100644 --- a/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts +++ b/packages/twenty-front/src/modules/ai/states/agentChatUsageState.ts @@ -1,10 +1,19 @@ import { atom } from 'recoil'; -export type AgentChatUsageState = { +export type AgentChatLastMessageUsage = { inputTokens: number; outputTokens: number; - totalTokens: number; + cachedInputTokens: number; + inputCredits: number; + outputCredits: number; +}; + +export type AgentChatUsageState = { + lastMessage: AgentChatLastMessageUsage | null; + conversationSize: number; contextWindowTokens: number; + inputTokens: number; + outputTokens: number; inputCredits: number; outputCredits: number; }; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts index 9270e0ff49b..c845f9bae18 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/groupThreadsByDate.test.ts @@ -8,6 +8,7 @@ describe('groupThreadsByDate', () => { totalInputTokens: 0, totalOutputTokens: 0, contextWindowTokens: null, + conversationSize: 0, totalInputCredits: 0, totalOutputCredits: 0, }; diff --git a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts new file mode 100644 index 00000000000..b6f9524eeca --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts @@ -0,0 +1,99 @@ +import { type ToolInput } from '@/ai/types/ToolInput'; +import { isDefined } from 'twenty-shared/utils'; + +const extractSearchQuery = (input: ToolInput): string => { + if (!input) { + return ''; + } + + if ( + typeof input === 'object' && + 'query' in input && + typeof input.query === 'string' + ) { + return input.query; + } + + if ( + typeof input === 'object' && + 'action' in input && + isDefined(input.action) && + typeof input.action === 'object' && + 'query' in input.action && + typeof input.action.query === 'string' + ) { + return input.action.query; + } + + return ''; +}; + +const extractLoadingMessage = (input: ToolInput): string => { + if ( + isDefined(input) && + typeof input === 'object' && + 'loadingMessage' in input && + typeof input.loadingMessage === 'string' + ) { + return input.loadingMessage; + } + + return 'Processing...'; +}; + +export const resolveToolInput = ( + input: ToolInput, + toolName: string, +): { resolvedInput: ToolInput; resolvedToolName: string } => { + if ( + toolName === 'execute_tool' && + isDefined(input) && + typeof input === 'object' && + 'toolName' in input && + 'arguments' in input + ) { + return { + resolvedInput: input.arguments as ToolInput, + resolvedToolName: String(input.toolName), + }; + } + + return { resolvedInput: input, resolvedToolName: toolName }; +}; + +const extractLearnToolNames = (input: ToolInput): string => { + if ( + isDefined(input) && + typeof input === 'object' && + 'toolNames' in input && + Array.isArray(input.toolNames) + ) { + return input.toolNames.join(', '); + } + + return ''; +}; + +export const getToolDisplayMessage = ( + input: ToolInput, + toolName: string, + isFinished?: boolean, +): string => { + const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName); + + if (resolvedToolName === 'web_search') { + const query = extractSearchQuery(resolvedInput); + const action = isFinished ? 'Searched' : 'Searching'; + + return query ? `${action} the web for '${query}'` : `${action} the web`; + } + + if (resolvedToolName === 'learn_tools') { + const names = extractLearnToolNames(resolvedInput); + const action = isFinished ? 'Learned' : 'Learning'; + + return names ? `${action} ${names}` : `${action} tools...`; + } + + return extractLoadingMessage(resolvedInput); +}; diff --git a/packages/twenty-front/src/modules/ai/utils/getToolIcon.ts b/packages/twenty-front/src/modules/ai/utils/getToolIcon.ts index bc48f79f494..9c968c76a44 100644 --- a/packages/twenty-front/src/modules/ai/utils/getToolIcon.ts +++ b/packages/twenty-front/src/modules/ai/utils/getToolIcon.ts @@ -1,6 +1,16 @@ -import { IconDatabase, IconMail, IconTool, IconWorld } from 'twenty-ui/display'; +import { + IconBook2, + IconDatabase, + IconMail, + IconTool, + IconWorld, +} from 'twenty-ui/display'; const TOOL_ICON_MAPPINGS = [ + { + keywords: ['learn_tools'], + icon: IconBook2, + }, { keywords: ['email'], icon: IconMail, diff --git a/packages/twenty-front/src/modules/ai/utils/getWebSearchToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getWebSearchToolDisplayMessage.ts deleted file mode 100644 index be53a90dc80..00000000000 --- a/packages/twenty-front/src/modules/ai/utils/getWebSearchToolDisplayMessage.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { type ToolInput } from '@/ai/types/ToolInput'; -import { isDefined } from 'twenty-shared/utils'; - -const extractSearchQuery = (input: ToolInput): string => { - if (!input) { - return ''; - } - - if ( - typeof input === 'object' && - 'query' in input && - typeof input.query === 'string' - ) { - return input.query; - } - - if ( - typeof input === 'object' && - 'action' in input && - isDefined(input.action) && - typeof input.action === 'object' && - 'query' in input.action && - typeof input.action.query === 'string' - ) { - return input.action.query; - } - - return ''; -}; - -const extractLoadingMessage = (input: ToolInput): string => { - if ( - isDefined(input) && - typeof input === 'object' && - 'loadingMessage' in input && - typeof input.loadingMessage === 'string' - ) { - return input.loadingMessage; - } - - return 'Processing...'; -}; - -export const getToolDisplayMessage = ( - input: ToolInput, - toolName: string, - isFinished?: boolean, -): string => { - if (toolName === 'web_search') { - const query = extractSearchQuery(input); - const action = isFinished ? 'Searched' : 'Searching'; - return query ? `${action} the web for '${query}'` : `${action} the web`; - } - - return extractLoadingMessage(input); -}; diff --git a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx index 4fe5b69a32e..91bb70e77f3 100644 --- a/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx +++ b/packages/twenty-front/src/modules/app/components/SettingsRoutes.tsx @@ -185,6 +185,12 @@ const SettingsSkillForm = lazy(() => })), ); +const SettingsAIPrompts = lazy(() => + import('~/pages/settings/ai/SettingsAIPrompts').then((module) => ({ + default: module.SettingsAIPrompts, + })), +); + const SettingsWorkspaceMembers = lazy(() => import('~/pages/settings/members/SettingsWorkspaceMembers').then( (module) => ({ @@ -442,6 +448,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => ( element={} /> } /> + } /> } diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index d13197b7585..5483ef84d0a 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -36,6 +36,7 @@ export type CurrentWorkspace = Pick< | 'eventLogRetentionDays' | 'fastModel' | 'smartModel' + | 'aiAdditionalInstructions' | 'editableProfileFields' > & { defaultRole?: Omit | null; diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 6c446f28f69..607f0909270 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -88,6 +88,7 @@ export const USER_QUERY_FRAGMENT = gql` } fastModel smartModel + aiAdditionalInstructions isTwoFactorAuthenticationEnforced trashRetentionDays eventLogRetentionDays diff --git a/packages/twenty-front/src/modules/workspace/graphql/queries/getAISystemPromptPreview.ts b/packages/twenty-front/src/modules/workspace/graphql/queries/getAISystemPromptPreview.ts new file mode 100644 index 00000000000..f7e80bb57f9 --- /dev/null +++ b/packages/twenty-front/src/modules/workspace/graphql/queries/getAISystemPromptPreview.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +export const GET_AI_SYSTEM_PROMPT_PREVIEW = gql` + query GetAISystemPromptPreview { + getAISystemPromptPreview { + sections { + title + content + estimatedTokenCount + } + estimatedTokenCount + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx index 61913d3443a..876e8162e11 100644 --- a/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAI.tsx @@ -1,3 +1,7 @@ +import styled from '@emotion/styled'; +import { Link } from 'react-router-dom'; + +import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; import { TabList } from '@/ui/layout/tab-list/components/TabList'; @@ -7,13 +11,25 @@ import { SettingsPath } from 'twenty-shared/types'; import { getSettingsPath } from 'twenty-shared/utils'; import { t } from '@lingui/core/macro'; -import { IconSettings, IconSparkles, IconTool } from 'twenty-ui/display'; +import { + H2Title, + IconFileText, + IconSettings, + IconSparkles, + IconTool, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { Card, Section } from 'twenty-ui/layout'; import { SettingsAIMCP } from './components/SettingsAIMCP'; import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings'; import { SettingsSkillsTable } from './components/SettingsSkillsTable'; import { SettingsToolsTable } from './components/SettingsToolsTable'; import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs'; +const StyledLink = styled(Link)` + text-decoration: none; +`; + export const SettingsAI = () => { const activeTabId = useRecoilComponentValue( activeTabIdComponentState, @@ -63,6 +79,28 @@ export const SettingsAI = () => { {isSettingsTab && ( <> +
+ + + +
)} diff --git a/packages/twenty-front/src/pages/settings/ai/SettingsAIPrompts.tsx b/packages/twenty-front/src/pages/settings/ai/SettingsAIPrompts.tsx new file mode 100644 index 00000000000..36d3b2cbe99 --- /dev/null +++ b/packages/twenty-front/src/pages/settings/ai/SettingsAIPrompts.tsx @@ -0,0 +1,263 @@ +import { ApolloError } from '@apollo/client'; +import styled from '@emotion/styled'; +import { useDebouncedCallback } from 'use-debounce'; + +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput'; +import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer'; +import { t } from '@lingui/core/macro'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { SettingsPath } from 'twenty-shared/types'; +import { getSettingsPath, isDefined } from 'twenty-shared/utils'; +import { H2Title } from 'twenty-ui/display'; +import { Section } from 'twenty-ui/layout'; +import { + useGetAiSystemPromptPreviewQuery, + useUpdateWorkspaceMutation, +} from '~/generated-metadata/graphql'; + +const StyledFormContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(4)}; +`; + +const StyledTokenBadge = styled.span` + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.xs}; + padding: ${({ theme }) => theme.spacing(0.5)} + ${({ theme }) => theme.spacing(1.5)}; + white-space: nowrap; +`; + +export const SettingsAIPrompts = () => { + const { enqueueErrorSnackBar } = useSnackBar(); + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const { data: previewData, loading: previewLoading } = + useGetAiSystemPromptPreviewQuery(); + + const [workspaceInstructions, setWorkspaceInstructions] = useState( + currentWorkspace?.aiAdditionalInstructions ?? '', + ); + const [originalInstructions, setOriginalInstructions] = useState( + currentWorkspace?.aiAdditionalInstructions ?? '', + ); + + const handleWorkspaceInstructionsInit = () => { + if (currentWorkspace?.aiAdditionalInstructions !== undefined) { + setWorkspaceInstructions(currentWorkspace.aiAdditionalInstructions ?? ''); + setOriginalInstructions(currentWorkspace.aiAdditionalInstructions ?? ''); + } + }; + + if ( + currentWorkspace?.aiAdditionalInstructions !== undefined && + originalInstructions === '' && + currentWorkspace.aiAdditionalInstructions !== null && + currentWorkspace.aiAdditionalInstructions !== originalInstructions + ) { + handleWorkspaceInstructionsInit(); + } + + const autoSave = useDebouncedCallback(async (newValue: string) => { + if (!currentWorkspace?.id || newValue === originalInstructions) { + return; + } + + try { + setCurrentWorkspace({ + ...currentWorkspace, + aiAdditionalInstructions: newValue || null, + }); + + await updateWorkspace({ + variables: { + input: { + aiAdditionalInstructions: newValue || null, + }, + }, + }); + + setOriginalInstructions(newValue); + } catch (error) { + setCurrentWorkspace({ + ...currentWorkspace, + aiAdditionalInstructions: originalInstructions || null, + }); + + if (error instanceof ApolloError) { + enqueueErrorSnackBar({ + apolloError: error, + }); + } else { + enqueueErrorSnackBar({ + message: t`Failed to save workspace instructions`, + }); + } + } + }, 1000); + + const handleWorkspaceInstructionsChange = (value: string) => { + setWorkspaceInstructions(value); + autoSave(value); + }; + + const preview = previewData?.getAISystemPromptPreview; + const sections = preview?.sections ?? []; + + const buildUserContextPreview = (): string => { + if (!isDefined(currentWorkspaceMember)) { + return ''; + } + + const parts = [ + `**${t`User`}:** ${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName}`.trim(), + `**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`, + ]; + + if (isDefined(currentWorkspaceMember.timeZone)) { + parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`); + } + + return parts.join('\n\n'); + }; + + const userContextPreview = buildUserContextPreview(); + + const promptSections = sections.filter( + (section) => + section.title !== 'Workspace Instructions' && + section.title !== 'User Context', + ); + + const formatTokenCount = (count: number): string => { + if (count >= 1000) { + const kTokens = (count / 1000).toFixed(1); + + return t`~${kTokens}k tokens`; + } + + return t`~${count} tokens`; + }; + + const totalTokenCount = isDefined(preview) + ? formatTokenCount(preview.estimatedTokenCount) + : ''; + const pageTitle = isDefined(preview) + ? t`System Prompt (${totalTokenCount})` + : t`System Prompt`; + + return ( + + + {promptSections.map((section) => ( +
+ + {formatTokenCount(section.estimatedTokenCount)} + + } + /> + + {}} + enableFullScreen={true} + fullScreenBreadcrumbs={[ + { + children: t`System Prompt`, + href: '#', + }, + { + children: section.title, + }, + ]} + minHeight={120} + maxWidth={700} + /> + +
+ ))} + +
+ + + + +
+ +
+ + + {}} + enableFullScreen={false} + minHeight={80} + maxWidth={700} + /> + +
+
+
+ ); +}; diff --git a/packages/twenty-server/package.json b/packages/twenty-server/package.json index bc38be9d7e0..6aef7bd23b3 100644 --- a/packages/twenty-server/package.json +++ b/packages/twenty-server/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@ai-sdk/anthropic": "^2.0.17", + "@ai-sdk/groq": "^2.0.34", "@ai-sdk/openai": "^2.0.30", "@ai-sdk/provider-utils": "^3.0.9", "@ai-sdk/xai": "^2.0.19", diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770311652940-add-ai-additional-instructions.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770311652940-add-ai-additional-instructions.ts new file mode 100644 index 00000000000..1b37a7fd04d --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770311652940-add-ai-additional-instructions.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddAiAdditionalInstructions1770311652940 + implements MigrationInterface +{ + name = 'AddAiAdditionalInstructions1770311652940'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD "aiAdditionalInstructions" text`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP COLUMN "aiAdditionalInstructions"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1770400000000-addConversationSizeToAgentChatThread.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770400000000-addConversationSizeToAgentChatThread.ts new file mode 100644 index 00000000000..d8238b14e99 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1770400000000-addConversationSizeToAgentChatThread.ts @@ -0,0 +1,19 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddConversationSizeToAgentChatThread1770400000000 + implements MigrationInterface +{ + name = 'AddConversationSizeToAgentChatThread1770400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" ADD COLUMN "conversationSize" integer NOT NULL DEFAULT 0`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agentChatThread" DROP COLUMN "conversationSize"`, + ); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts index 10ed4e4d350..160974fe36c 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/billing.resolver.ts @@ -23,6 +23,10 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service'; import { BillingService } from 'src/engine/core-modules/billing/services/billing.service'; import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util'; +import { + INTERNAL_CREDITS_PER_DISPLAY_CREDIT, + toDisplayCredits, +} from 'src/engine/core-modules/billing/utils/to-display-credits.util'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { type UserEntity } from 'src/engine/core-modules/user/user.entity'; @@ -298,7 +302,17 @@ export class BillingResolver { async getMeteredProductsUsage( @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - return await this.billingUsageService.getMeteredProductsUsage(workspace); + const usageData = + await this.billingUsageService.getMeteredProductsUsage(workspace); + + return usageData.map((item) => ({ + ...item, + usedCredits: toDisplayCredits(item.usedCredits), + grantedCredits: toDisplayCredits(item.grantedCredits), + rolloverCredits: toDisplayCredits(item.rolloverCredits), + totalGrantedCredits: toDisplayCredits(item.totalGrantedCredits), + unitPriceCents: item.unitPriceCents * INTERNAL_CREDITS_PER_DISPLAY_CREDIT, + })); } @Mutation(() => BillingUpdateOutput) diff --git a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts index 9111b0b2a48..95b938bbac1 100644 --- a/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts +++ b/packages/twenty-server/src/engine/core-modules/billing/dtos/outputs/billing-metered-product-usage.output.ts @@ -1,4 +1,4 @@ -import { Field, ObjectType } from '@nestjs/graphql'; +import { Field, Float, ObjectType } from '@nestjs/graphql'; import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum'; @@ -13,18 +13,18 @@ export class BillingMeteredProductUsageOutput { @Field(() => Date) periodEnd: Date; - @Field(() => Number) + @Field(() => Float) usedCredits: number; - @Field(() => Number) + @Field(() => Float) grantedCredits: number; - @Field(() => Number) + @Field(() => Float) rolloverCredits: number; - @Field(() => Number) + @Field(() => Float) totalGrantedCredits: number; - @Field(() => Number) + @Field(() => Float) unitPriceCents: number; } diff --git a/packages/twenty-server/src/engine/core-modules/billing/utils/to-display-credits.util.ts b/packages/twenty-server/src/engine/core-modules/billing/utils/to-display-credits.util.ts new file mode 100644 index 00000000000..2c4d1bd3a80 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/billing/utils/to-display-credits.util.ts @@ -0,0 +1,9 @@ +// Internal credits use micro-precision: $1 = 1,000,000 internal credits +// Display credits are 1000x coarser: $1 = 1,000 display credits +// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars) +export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000; + +// Converts internal (high-precision) credits to user-facing display credits. +// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5). +export const toDisplayCredits = (internalCredits: number): number => + Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts index 5a35bf915dc..f1fd22a7f03 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts @@ -3,9 +3,11 @@ import { Module } from '@nestjs/common'; import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module'; import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; +import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service'; 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 { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service'; import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; @@ -23,14 +25,18 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache providers: [ CommonApiContextBuilderService, CreateRecordService, + CreateManyRecordsService, UpdateRecordService, + UpdateManyRecordsService, DeleteRecordService, FindRecordsService, UpsertRecordService, ], exports: [ CreateRecordService, + CreateManyRecordsService, UpdateRecordService, + UpdateManyRecordsService, DeleteRecordService, FindRecordsService, UpsertRecordService, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/create-many-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-many-records.service.ts new file mode 100644 index 00000000000..1ba38dadc8f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/create-many-records.service.ts @@ -0,0 +1,109 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { FieldActorSource } from 'twenty-shared/types'; +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; + +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; +import { type CreateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/create-many-records-params.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; + +@Injectable() +export class CreateManyRecordsService { + private readonly logger = new Logger(CreateManyRecordsService.name); + + constructor( + private readonly commonCreateManyRunner: CommonCreateManyQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, + ) {} + + async execute(params: CreateManyRecordsParams): Promise { + const { objectName, objectRecords, authContext } = params; + + try { + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to create: Object cannot be created by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const actorMetadata = params.createdBy ?? { + source: FieldActorSource.WORKFLOW, + name: 'Workflow', + }; + + const cleanedRecords = objectRecords.map((record) => ({ + ...removeUndefinedFromRecord(record), + createdBy: actorMetadata, + })); + + const createdRecords = await this.commonCreateManyRunner.execute( + { + data: cleanedRecords, + selectedFields, + }, + queryRunnerContext, + ); + + this.logger.log( + `Created ${createdRecords.length} records in ${objectName}`, + ); + + return { + success: true, + message: `Created ${createdRecords.length} records in ${objectName}`, + result: params.slimResponse + ? createdRecords.map((record) => ({ id: record.id })) + : createdRecords, + recordReferences: createdRecords.map((record) => ({ + objectNameSingular: objectName, + recordId: record.id, + displayName: getRecordDisplayName( + record, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + })), + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to create records in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to create records: ${error}`); + + return { + success: false, + message: `Failed to create records in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to create records', + }; + } + } +} 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 8172881cf7b..1cc4ce4d067 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 @@ -74,7 +74,7 @@ export class CreateRecordService { return { success: true, message: `Record created successfully in ${objectName}`, - result: createdRecord, + result: params.slimResponse ? { id: createdRecord.id } : createdRecord, recordReferences: [ { objectNameSingular: objectName, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/update-many-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-many-records.service.ts new file mode 100644 index 00000000000..b78dbf51d52 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/update-many-records.service.ts @@ -0,0 +1,97 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow'; + +import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; +import { type UpdateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/update-many-records-params.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; + +@Injectable() +export class UpdateManyRecordsService { + private readonly logger = new Logger(UpdateManyRecordsService.name); + + constructor( + private readonly commonUpdateManyRunner: CommonUpdateManyQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, + ) {} + + async execute(params: UpdateManyRecordsParams): Promise { + const { objectName, filter, data, authContext } = params; + + try { + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); + + if ( + !canObjectBeManagedByWorkflow({ + nameSingular: flatObjectMetadata.nameSingular, + isSystem: flatObjectMetadata.isSystem, + }) + ) { + throw new RecordCrudException( + 'Failed to update: Object cannot be updated by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const cleanedData = removeUndefinedFromRecord(data); + + const updatedRecords = await this.commonUpdateManyRunner.execute( + { filter, data: cleanedData, selectedFields }, + queryRunnerContext, + ); + + this.logger.log( + `Updated ${updatedRecords.length} records in ${objectName}`, + ); + + return { + success: true, + message: `Updated ${updatedRecords.length} records in ${objectName}`, + result: params.slimResponse + ? updatedRecords.map((record) => ({ id: record.id })) + : updatedRecords, + recordReferences: updatedRecords.map((record) => ({ + objectNameSingular: objectName, + recordId: record.id, + displayName: getRecordDisplayName( + record, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + })), + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to update records in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to update records: ${error}`); + + return { + success: false, + message: `Failed to update records in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to update records', + }; + } + } +} 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 8b8b161269c..a96d790e62f 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 @@ -103,7 +103,7 @@ export class UpdateRecordService { return { success: true, message: `Record updated successfully in ${objectName}`, - result: updatedRecord, + result: params.slimResponse ? { id: objectRecordId } : updatedRecord, recordReferences: [ { objectNameSingular: objectName, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts b/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts deleted file mode 100644 index 4cf640f7b0e..00000000000 --- a/packages/twenty-server/src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { type ToolSet } from 'ai'; - -import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service'; -import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service'; -import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service'; -import { type UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; -import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util'; -import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util'; -import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema'; -import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema'; -import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema'; -import { - type ObjectWithPermission, - type ToolGeneratorContext, -} from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; - -// Dependencies required by the direct record tools factory -export type DirectRecordToolsDeps = { - createRecordService: CreateRecordService; - updateRecordService: UpdateRecordService; - deleteRecordService: DeleteRecordService; - findRecordsService: FindRecordsService; -}; - -export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => { - return ( - { - objectMetadata, - restrictedFields, - canCreate, - canRead, - canUpdate, - canDelete, - }: ObjectWithPermission, - context: ToolGeneratorContext, - ): ToolSet => { - const tools: ToolSet = {}; - - // Skip generating tools if no auth context is provided - if (!context.authContext) { - return tools; - } - - // Capture authContext in a constant for use in async callbacks - const authContext = context.authContext; - - if (canRead) { - tools[`find_${objectMetadata.namePlural}`] = { - description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`, - inputSchema: generateFindToolInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - const { - loadingMessage: _, - limit, - offset, - orderBy, - ...filter - } = parameters; - - return deps.findRecordsService.execute({ - objectName: objectMetadata.nameSingular, - filter, - orderBy, - limit, - offset, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - }); - }, - }; - - tools[`find_one_${objectMetadata.nameSingular}`] = { - description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, - inputSchema: FindOneToolInputSchema, - execute: async (parameters) => { - return deps.findRecordsService.execute({ - objectName: objectMetadata.nameSingular, - filter: { id: { eq: parameters.id } }, - limit: 1, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - }); - }, - }; - } - - if (canCreate) { - tools[`create_${objectMetadata.nameSingular}`] = { - description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, - inputSchema: generateCreateRecordInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - const { loadingMessage: _, ...objectRecord } = parameters; - - return deps.createRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecord, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - createdBy: context.actorContext, - }); - }, - }; - } - - if (canUpdate) { - tools[`update_${objectMetadata.nameSingular}`] = { - description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, - inputSchema: generateUpdateRecordInputSchema( - objectMetadata, - restrictedFields, - ), - execute: async (parameters) => { - const { loadingMessage: _, id, ...allFields } = parameters; - - const objectRecord = Object.fromEntries( - Object.entries(allFields).filter( - ([, value]) => value !== undefined, - ), - ); - - return deps.updateRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecordId: id, - objectRecord, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - }); - }, - }; - } - - if (canDelete) { - tools[`soft_delete_${objectMetadata.nameSingular}`] = { - description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`, - inputSchema: SoftDeleteToolInputSchema, - execute: async (parameters) => { - return deps.deleteRecordService.execute({ - objectName: objectMetadata.nameSingular, - objectRecordId: parameters.id, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - soft: true, - }); - }, - }; - } - - return tools; - }; -}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/create-many-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/create-many-records-params.type.ts new file mode 100644 index 00000000000..c3e3cf627a4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/create-many-records-params.type.ts @@ -0,0 +1,14 @@ +import { type ActorMetadata } from 'twenty-shared/types'; + +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type CreateManyRecordsParams = { + objectName: string; + objectRecords: ObjectRecordProperties[]; + authContext: WorkspaceAuthContext; + rolePermissionConfig?: RolePermissionConfig; + createdBy?: ActorMetadata; + slimResponse?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-execution-context.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-execution-context.type.ts index b3c27d4cc02..e6455f665fd 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-execution-context.type.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/record-crud-execution-context.type.ts @@ -4,4 +4,5 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm export type RecordCrudExecutionContext = { authContext: WorkspaceAuthContext; rolePermissionConfig?: RolePermissionConfig; + slimResponse?: boolean; }; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/update-many-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/update-many-records-params.type.ts new file mode 100644 index 00000000000..c29a48c2a9a --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/update-many-records-params.type.ts @@ -0,0 +1,14 @@ +import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; + +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type UpdateManyRecordsParams = { + objectName: string; + filter: Partial; + data: ObjectRecordProperties; + authContext: WorkspaceAuthContext; + rolePermissionConfig?: RolePermissionConfig; + slimResponse?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util.ts new file mode 100644 index 00000000000..fcda59ec20e --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util.ts @@ -0,0 +1,26 @@ +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; + +export const generateCreateManyRecordInputSchema = ( + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields?: RestrictedFieldsPermissions, +) => { + const recordSchema = generateRecordPropertiesZodSchema( + objectMetadata, + false, + restrictedFields, + ); + + return z.object({ + records: z + .array(recordSchema) + .min(1) + .max(20) + .describe( + 'Array of records to create. Each record should contain the required fields. Maximum 20 records per call.', + ), + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts new file mode 100644 index 00000000000..25c87a72c2c --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts @@ -0,0 +1,31 @@ +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema'; +import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; + +export const generateUpdateManyRecordInputSchema = ( + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields?: RestrictedFieldsPermissions, +) => { + const { filterSchema } = generateRecordFilterSchema( + objectMetadata, + restrictedFields, + ); + + const dataSchema = generateRecordPropertiesZodSchema( + objectMetadata, + false, + restrictedFields, + ).partial(); + + return z.object({ + filter: filterSchema.describe( + 'Filter to select which records to update. Supports field-level filters and logical operators (or, and, not). WARNING: A broad filter may update many records at once. Always verify the filter scope with a find query first.', + ), + data: dataSchema.describe( + 'The field values to apply to all matching records. Only include fields you want to change.', + ), + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema.ts new file mode 100644 index 00000000000..406af2cd2c7 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const DeleteToolInputSchema = z.object({ + id: z.string().uuid().describe('The unique UUID of the record to delete'), +}); + +export type DeleteToolInput = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts index 1764342696e..ac34ddc35a4 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts @@ -1,64 +1,17 @@ -import { - FieldMetadataType, - RelationType, - type RestrictedFieldsPermissions, -} from 'twenty-shared/types'; +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; import { z } from 'zod'; import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; -import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema'; -import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util'; -import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema'; export const generateFindToolInputSchema = ( objectMetadata: ObjectMetadataForToolSchema, restrictedFields?: RestrictedFieldsPermissions, ) => { - const filterShape: Record = {}; - - objectMetadata.fields.forEach((field) => { - if (shouldExcludeFieldFromAgentToolSchema(field)) { - return; - } - - if (restrictedFields?.[field.id]?.canRead === false) { - return; - } - - const filterSchema = generateFieldFilterZodSchema(field); - - if (!filterSchema) { - return; - } - - const isManyToOneRelationField = - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && - field.settings?.relationType === RelationType.MANY_TO_ONE; - - filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = - filterSchema; - }); - - // Create the base filter schema with field-level filters + logical operators - // This matches the RecordGqlOperationFilter format used by the frontend - const filterSchema: z.ZodTypeAny = z.lazy(() => - z - .object({ - ...filterShape, - or: z - .array(filterSchema) - .optional() - .describe('OR condition - matches if ANY of the filters match'), - and: z - .array(filterSchema) - .optional() - .describe('AND condition - matches if ALL filters match'), - not: filterSchema - .optional() - .describe('NOT condition - matches if the filter does NOT match'), - }) - .partial(), + const { filterShape, filterSchema } = generateRecordFilterSchema( + objectMetadata, + restrictedFields, ); return z.object({ @@ -66,9 +19,11 @@ export const generateFindToolInputSchema = ( .number() .int() .positive() - .max(1000) - .default(100) - .describe('Maximum number of records to return (default: 100)'), + .max(100) + .default(10) + .describe( + 'Maximum number of records to return (default: 10, max: 100). Start small and increase only if needed.', + ), offset: z .number() .int() diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts new file mode 100644 index 00000000000..e4ff1401f02 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts @@ -0,0 +1,67 @@ +import { + FieldMetadataType, + RelationType, + type RestrictedFieldsPermissions, +} from 'twenty-shared/types'; +import { z } from 'zod'; + +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; +import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +// Builds the per-field filter shape and full recursive filter schema +// for a given object metadata, reusable across find and updateMany tools +export const generateRecordFilterSchema = ( + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields?: RestrictedFieldsPermissions, +): { + filterShape: Record; + filterSchema: z.ZodTypeAny; +} => { + const filterShape: Record = {}; + + objectMetadata.fields.forEach((field) => { + if (shouldExcludeFieldFromAgentToolSchema(field)) { + return; + } + + if (restrictedFields?.[field.id]?.canRead === false) { + return; + } + + const fieldFilter = generateFieldFilterZodSchema(field); + + if (!fieldFilter) { + return; + } + + const isManyToOneRelationField = + isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && + field.settings?.relationType === RelationType.MANY_TO_ONE; + + filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = + fieldFilter; + }); + + const filterSchema: z.ZodTypeAny = z.lazy(() => + z + .object({ + ...filterShape, + or: z + .array(filterSchema) + .optional() + .describe('OR condition - matches if ANY of the filters match'), + and: z + .array(filterSchema) + .optional() + .describe('AND condition - matches if ALL filters match'), + not: filterSchema + .optional() + .describe('NOT condition - matches if the filter does NOT match'), + }) + .partial(), + ); + + return { filterShape, filterSchema }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts index 0757414660e..e9d20195427 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts @@ -23,6 +23,8 @@ const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => { case 'createdAt': case 'updatedAt': case 'deletedAt': + case 'createdBy': + case 'updatedBy': return false; default: return true; @@ -253,7 +255,11 @@ export const generateRecordPropertiesZodSchema = ( break; } - if (field.description) { + if (field.name === 'position') { + fieldSchema = fieldSchema.describe( + 'Leave empty to place at the top of the list (recommended).', + ); + } else if (field.description) { fieldSchema = fieldSchema.describe(field.description); } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema.ts deleted file mode 100644 index 36778b3b76b..00000000000 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { z } from 'zod'; - -export const SoftDeleteToolInputSchema = z.object({ - id: z - .string() - .uuid() - .describe('The unique UUID of the record to soft delete'), -}); - -export type SoftDeleteToolInput = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/constants/common-preload-tools.const.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/common-preload-tools.const.ts new file mode 100644 index 00000000000..c5dcc1c3f91 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/constants/common-preload-tools.const.ts @@ -0,0 +1 @@ +export const COMMON_PRELOAD_TOOLS: string[] = ['search_help_center']; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts index 03e70bca120..6518361a5de 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/interfaces/tool-provider.interface.ts @@ -4,7 +4,7 @@ import { type ActorMetadata } from 'twenty-shared/types'; import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; 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'; @@ -27,7 +27,7 @@ export type ToolProviderContext = { // Options for tool retrieval export type ToolRetrievalOptions = { categories?: ToolCategory[]; - excludeTools?: ToolType[]; + excludeTools?: string[]; wrapWithErrorContext?: boolean; }; @@ -36,5 +36,15 @@ export interface ToolProvider { isAvailable(context: ToolProviderContext): Promise; + generateDescriptors(context: ToolProviderContext): Promise; +} + +// NativeModelToolProvider is special: SDK-native tools are opaque and not +// serializable. It keeps the old generateTools() contract. +export interface NativeToolProvider { + readonly category: ToolCategory; + + isAvailable(context: ToolProviderContext): Promise; + generateTools(context: ToolProviderContext): Promise; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/__tests__/strip-empty-values.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/__tests__/strip-empty-values.util.spec.ts new file mode 100644 index 00000000000..42f8330e186 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/__tests__/strip-empty-values.util.spec.ts @@ -0,0 +1,153 @@ +import { stripEmptyValues } from 'src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util'; + +describe('stripEmptyValues', () => { + it('should remove null values', () => { + expect(stripEmptyValues({ a: 1, b: null })).toEqual({ a: 1 }); + }); + + it('should remove undefined values', () => { + expect(stripEmptyValues({ a: 1, b: undefined })).toEqual({ a: 1 }); + }); + + it('should remove empty strings', () => { + expect(stripEmptyValues({ a: 'hello', b: '' })).toEqual({ a: 'hello' }); + }); + + it('should remove empty objects', () => { + expect(stripEmptyValues({ a: 1, b: {} })).toEqual({ a: 1 }); + }); + + it('should remove empty arrays', () => { + expect(stripEmptyValues({ a: 1, b: [] })).toEqual({ a: 1 }); + }); + + it('should preserve non-empty values', () => { + expect(stripEmptyValues({ a: 0, b: false })).toEqual({ a: 0, b: false }); + }); + + it('should recursively strip nested objects', () => { + const input = { + name: 'Acme', + address: { + city: null, + street: null, + state: null, + country: 'US', + }, + links: { + primaryLinkUrl: '', + primaryLinkLabel: '', + secondaryLinks: [], + }, + }; + + expect(stripEmptyValues(input)).toEqual({ + name: 'Acme', + address: { country: 'US' }, + }); + }); + + it('should remove deeply nested empty objects', () => { + const input = { + name: 'Test', + nested: { + deep: { + empty: null, + alsoEmpty: '', + }, + }, + }; + + expect(stripEmptyValues(input)).toEqual({ name: 'Test' }); + }); + + it('should strip empty values from arrays of objects', () => { + const input = { + records: [ + { id: '1', name: 'Acme', website: null, industry: '' }, + { id: '2', name: 'Beta', website: 'beta.com', industry: null }, + ], + }; + + expect(stripEmptyValues(input)).toEqual({ + records: [ + { id: '1', name: 'Acme' }, + { id: '2', name: 'Beta', website: 'beta.com' }, + ], + }); + }); + + it('should return undefined for entirely empty input', () => { + expect(stripEmptyValues({ a: null, b: '', c: {} })).toBeUndefined(); + }); + + it('should handle a realistic tool output', () => { + const toolOutput = { + success: true, + message: 'Found 2 company records', + result: { + records: [ + { + id: 'abc-123', + name: 'Acme Corp', + employees: 500, + industry: 'Technology', + website: null, + address: { + city: null, + street: null, + state: null, + country: null, + }, + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + deletedAt: null, + }, + ], + count: 1, + }, + recordReferences: [ + { + objectNameSingular: 'company', + recordId: 'abc-123', + displayName: 'Acme Corp', + }, + ], + }; + + expect(stripEmptyValues(toolOutput)).toEqual({ + success: true, + message: 'Found 2 company records', + result: { + records: [ + { + id: 'abc-123', + name: 'Acme Corp', + employees: 500, + industry: 'Technology', + createdAt: '2024-01-01', + updatedAt: '2024-01-02', + }, + ], + count: 1, + }, + recordReferences: [ + { + objectNameSingular: 'company', + recordId: 'abc-123', + displayName: 'Acme Corp', + }, + ], + }); + }); + + it('should handle primitive values', () => { + expect(stripEmptyValues(42)).toBe(42); + expect(stripEmptyValues('hello')).toBe('hello'); + expect(stripEmptyValues(true)).toBe(true); + expect(stripEmptyValues(false)).toBe(false); + expect(stripEmptyValues(null)).toBeUndefined(); + expect(stripEmptyValues(undefined)).toBeUndefined(); + expect(stripEmptyValues('')).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util.ts new file mode 100644 index 00000000000..856692e60db --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util.ts @@ -0,0 +1,13 @@ +import { stripEmptyValues } from './strip-empty-values.util'; + +// Compacts a tool output by stripping empty values and flattening +// the result structure for efficient LLM token consumption. +// This is applied as a post-processing step on all tool results +// before they are returned to the AI model. +export const compactToolOutput = (output: unknown): unknown => { + if (!output || typeof output !== 'object') { + return output; + } + + return stripEmptyValues(output); +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util.ts new file mode 100644 index 00000000000..c2d21925ef6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util.ts @@ -0,0 +1,32 @@ +// Recursively strips null, undefined, empty strings, empty objects, +// and empty arrays from a value. Returns undefined if the entire +// value is empty so the caller can decide whether to include it. +export const stripEmptyValues = (value: unknown): unknown => { + if (value === null || value === undefined || value === '') { + return undefined; + } + + if (Array.isArray(value)) { + const cleaned = value + .map(stripEmptyValues) + .filter((item) => item !== undefined); + + return cleaned.length > 0 ? cleaned : undefined; + } + + if (typeof value === 'object') { + const result: Record = {}; + + for (const [key, val] of Object.entries(value as Record)) { + const stripped = stripEmptyValues(val); + + if (stripped !== undefined) { + result[key] = stripped; + } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + + return value; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util.ts new file mode 100644 index 00000000000..83d4c7a31e3 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util.ts @@ -0,0 +1,34 @@ +import { type ToolSet } from 'ai'; + +import { compactToolOutput } from './compact-tool-output.util'; + +// Wraps every tool's execute function with output serialization. +// The wrapper intercepts the raw tool result and applies compaction +// (strip nulls/empty, flatten) before the AI SDK serializes it +// into the conversation context. +// +// This is a composable utility — it can be chained with other +// wrappers like wrapToolsWithErrorContext. +export const wrapToolsWithOutputSerialization = (tools: ToolSet): ToolSet => { + const wrappedTools: ToolSet = {}; + + for (const [toolName, tool] of Object.entries(tools)) { + if (!tool.execute) { + wrappedTools[toolName] = tool; + continue; + } + + const originalExecute = tool.execute; + + wrappedTools[toolName] = { + ...tool, + execute: async (...args: Parameters) => { + const result = await originalExecute(...args); + + return compactToolOutput(result); + }, + }; + } + + return wrappedTools; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 269e0dddf8a..96d52ee6cfa 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -1,8 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { type ToolSet } from 'ai'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { type ZodObject, type ZodRawShape } from 'zod'; +import { z } from 'zod'; import { type ToolProvider, @@ -10,47 +9,64 @@ import { } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { + type StaticToolHandler, + ToolExecutorService, +} from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool'; import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool'; import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; -import { - type Tool, - type ToolExecutionContext, -} from 'src/engine/core-modules/tool/types/tool.type'; -import { - stripLoadingMessage, - wrapSchemaForExecution, -} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; +import { type Tool } from 'src/engine/core-modules/tool/types/tool.type'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @Injectable() export class ActionToolProvider implements ToolProvider { readonly category = ToolCategory.ACTION; + private readonly toolMap: Map; + constructor( private readonly httpTool: HttpTool, private readonly sendEmailTool: SendEmailTool, private readonly searchHelpCenterTool: SearchHelpCenterTool, private readonly codeInterpreterTool: CodeInterpreterTool, private readonly permissionsService: PermissionsService, - ) {} + private readonly toolExecutorService: ToolExecutorService, + ) { + this.toolMap = new Map([ + ['http_request', this.httpTool], + ['send_email', this.sendEmailTool], + ['search_help_center', this.searchHelpCenterTool], + ['code_interpreter', this.codeInterpreterTool], + ]); + + // Register each action tool as a static handler in the executor + for (const [toolId, tool] of this.toolMap) { + const handler: StaticToolHandler = { + execute: async (args: ToolInput, context: ToolProviderContext) => + tool.execute(args, { + workspaceId: context.workspaceId, + userId: context.userId, + userWorkspaceId: context.userWorkspaceId, + onCodeExecutionUpdate: context.onCodeExecutionUpdate, + }), + }; + + this.toolExecutorService.registerStaticHandler(toolId, handler); + } + } async isAvailable(_context: ToolProviderContext): Promise { - // Action tools are always available (individual tool permissions checked in generateTools) return true; } - async generateTools(context: ToolProviderContext): Promise { - const tools: ToolSet = {}; - - const executionContext: ToolExecutionContext = { - workspaceId: context.workspaceId, - userId: context.userId, - userWorkspaceId: context.userWorkspaceId, - onCodeExecutionUpdate: context.onCodeExecutionUpdate, - }; + async generateDescriptors( + context: ToolProviderContext, + ): Promise { + const descriptors: ToolDescriptor[] = []; const hasHttpPermission = await this.permissionsService.hasToolPermission( context.rolePermissionConfig, @@ -59,10 +75,7 @@ export class ActionToolProvider implements ToolProvider { ); if (hasHttpPermission) { - tools['http_request'] = this.createToolEntry( - this.httpTool, - executionContext, - ); + descriptors.push(this.buildDescriptor('http_request', this.httpTool)); } const hasEmailPermission = await this.permissionsService.hasToolPermission( @@ -72,15 +85,11 @@ export class ActionToolProvider implements ToolProvider { ); if (hasEmailPermission) { - tools['send_email'] = this.createToolEntry( - this.sendEmailTool, - executionContext, - ); + descriptors.push(this.buildDescriptor('send_email', this.sendEmailTool)); } - tools['search_help_center'] = this.createToolEntry( - this.searchHelpCenterTool, - executionContext, + descriptors.push( + this.buildDescriptor('search_help_center', this.searchHelpCenterTool), ); const hasCodeInterpreterPermission = @@ -91,23 +100,21 @@ export class ActionToolProvider implements ToolProvider { ); if (hasCodeInterpreterPermission) { - tools['code_interpreter'] = this.createToolEntry( - this.codeInterpreterTool, - executionContext, + descriptors.push( + this.buildDescriptor('code_interpreter', this.codeInterpreterTool), ); } - return tools; + return descriptors; } - private createToolEntry(tool: Tool, context: ToolExecutionContext) { + private buildDescriptor(toolId: string, tool: Tool): ToolDescriptor { return { + name: toolId, description: tool.description, - inputSchema: wrapSchemaForExecution( - tool.inputSchema as ZodObject, - ), - execute: async (parameters: ToolInput) => - tool.execute(stripLoadingMessage(parameters), context), + category: ToolCategory.ACTION, + inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType), + executionRef: { kind: 'static', toolId }, }; } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts index e197f57cd58..7de0bf6bdfa 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/dashboard-tool.provider.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common'; -import { type ToolSet } from 'ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { @@ -10,11 +9,14 @@ import { import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service'; @Injectable() -export class DashboardToolProvider implements ToolProvider { +export class DashboardToolProvider implements ToolProvider, OnModuleInit { readonly category = ToolCategory.DASHBOARD; constructor( @@ -22,8 +24,24 @@ export class DashboardToolProvider implements ToolProvider { @Inject(DASHBOARD_TOOL_SERVICE_TOKEN) private readonly dashboardToolService: DashboardToolWorkspaceService | null, private readonly permissionsService: PermissionsService, + private readonly toolExecutorService: ToolExecutorService, ) {} + onModuleInit(): void { + if (this.dashboardToolService) { + const service = this.dashboardToolService; + + this.toolExecutorService.registerCategoryGenerator( + ToolCategory.DASHBOARD, + async (context) => + service.generateDashboardTools( + context.workspaceId, + context.rolePermissionConfig, + ), + ); + } + } + async isAvailable(context: ToolProviderContext): Promise { if (!this.dashboardToolService) { return false; @@ -36,14 +54,18 @@ export class DashboardToolProvider implements ToolProvider { ); } - async generateTools(context: ToolProviderContext): Promise { + async generateDescriptors( + context: ToolProviderContext, + ): Promise { if (!this.dashboardToolService) { - return {}; + return []; } - return this.dashboardToolService.generateDashboardTools( + const toolSet = await this.dashboardToolService.generateDashboardTools( context.workspaceId, context.rolePermissionConfig, ); + + return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index 927f2b3a752..aa19990aac9 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -1,13 +1,11 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { type ToolSet } from 'ai'; import { type ObjectsPermissions, type ObjectsPermissionsByRoleId, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { Repository } from 'typeorm'; +import { camelToSnakeCase, isDefined } from 'twenty-shared/utils'; +import { z } from 'zod'; import { type ToolProvider, @@ -15,20 +13,16 @@ import { } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util'; -import { - AuthException, - AuthExceptionCode, -} from 'src/engine/core-modules/auth/auth.exception'; -import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; -import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.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 { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util'; +import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util'; +import { generateUpdateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util'; +import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util'; +import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema'; +import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema'; +import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { UserEntity } from 'src/engine/core-modules/user/user.entity'; -import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util'; 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 { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util'; @@ -41,66 +35,21 @@ export class DatabaseToolProvider implements ToolProvider { constructor( private readonly workspaceCacheService: WorkspaceCacheService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, - private readonly createRecordService: CreateRecordService, - private readonly updateRecordService: UpdateRecordService, - private readonly deleteRecordService: DeleteRecordService, - private readonly findRecordsService: FindRecordsService, - @InjectRepository(UserEntity) - private readonly userRepository: Repository, ) {} async isAvailable(_context: ToolProviderContext): Promise { - // Database tools are always available (per-object permissions checked in generateTools) return true; } - async generateTools(context: ToolProviderContext): Promise { - const tools: ToolSet = {}; + async generateDescriptors( + context: ToolProviderContext, + ): Promise { + const descriptors: ToolDescriptor[] = []; - // Both userId and userWorkspaceId are required for user-based tool generation if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) { - return tools; + return descriptors; } - const user = await this.userRepository.findOne({ - where: { - id: context.userId, - }, - }); - - if (!isDefined(user)) { - throw new AuthException( - 'User not found', - AuthExceptionCode.UNAUTHENTICATED, - ); - } - - const { flatWorkspaceMemberMaps } = - await this.workspaceCacheService.getOrRecompute(context.workspaceId, [ - 'flatWorkspaceMemberMaps', - ]); - - const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id]; - - const workspaceMember = isDefined(workspaceMemberId) - ? flatWorkspaceMemberMaps.byId[workspaceMemberId] - : undefined; - - if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) { - throw new AuthException( - 'Workspace member not found', - AuthExceptionCode.UNAUTHENTICATED, - ); - } - - const authContext: WorkspaceAuthContext = buildUserAuthContext({ - workspace: { id: context.workspaceId } as WorkspaceEntity, - userWorkspaceId: context.userWorkspaceId, - user, - workspaceMemberId, - workspaceMember, - }); - const { rolesPermissions } = await this.workspaceCacheService.getOrRecompute(context.workspaceId, [ 'rolesPermissions', @@ -112,7 +61,7 @@ export class DatabaseToolProvider implements ToolProvider { ); if (!objectPermissions) { - return tools; + return descriptors; } const { flatObjectMetadataMaps, flatFieldMetadataMaps } = @@ -127,17 +76,13 @@ export class DatabaseToolProvider implements ToolProvider { flatObjectMetadataMaps.byUniversalIdentifier, ) .filter(isDefined) - .filter((obj) => obj.isActive && !obj.isSystem); - - const factory = createDirectRecordToolsFactory({ - createRecordService: this.createRecordService, - updateRecordService: this.updateRecordService, - deleteRecordService: this.deleteRecordService, - findRecordsService: this.findRecordsService, - }); + .filter((obj) => obj.isActive); for (const flatObject of allFlatObjects) { - if (isWorkflowRelatedObject(flatObject)) { + if ( + isWorkflowRelatedObject(flatObject) || + isFavoriteRelatedObject(flatObject) + ) { continue; } @@ -155,27 +100,132 @@ export class DatabaseToolProvider implements ToolProvider { ), }; - const objectTools = factory( - { - objectMetadata, - restrictedFields: permission.restrictedFields, - canCreate: permission.canUpdateObjectRecords, - canRead: permission.canReadObjectRecords, - canUpdate: permission.canUpdateObjectRecords, - canDelete: permission.canSoftDeleteObjectRecords, - }, - { - workspaceId: context.workspaceId, - authContext, - rolePermissionConfig: context.rolePermissionConfig, - actorContext: context.actorContext, - }, - ); + const restrictedFields = permission.restrictedFields; + const snakePlural = camelToSnakeCase(objectMetadata.namePlural); + const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular); - Object.assign(tools, objectTools); + if (permission.canReadObjectRecords) { + descriptors.push({ + name: `find_${snakePlural}`, + description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema( + generateFindToolInputSchema(objectMetadata, restrictedFields), + ), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'find', + }, + objectName: objectMetadata.nameSingular, + operation: 'find', + }); + + descriptors.push({ + name: `find_one_${snakeSingular}`, + description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema(FindOneToolInputSchema), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'find_one', + }, + objectName: objectMetadata.nameSingular, + operation: 'find_one', + }); + } + + if (permission.canUpdateObjectRecords) { + descriptors.push({ + name: `create_${snakeSingular}`, + description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema( + generateCreateRecordInputSchema(objectMetadata, restrictedFields), + ), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'create', + }, + objectName: objectMetadata.nameSingular, + operation: 'create', + }); + + descriptors.push({ + name: `create_many_${snakePlural}`, + description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema( + generateCreateManyRecordInputSchema( + objectMetadata, + restrictedFields, + ), + ), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'create_many', + }, + objectName: objectMetadata.nameSingular, + operation: 'create_many', + }); + + descriptors.push({ + name: `update_${snakeSingular}`, + description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema( + generateUpdateRecordInputSchema(objectMetadata, restrictedFields), + ), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'update', + }, + objectName: objectMetadata.nameSingular, + operation: 'update', + }); + + descriptors.push({ + name: `update_many_${snakePlural}`, + description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema( + generateUpdateManyRecordInputSchema( + objectMetadata, + restrictedFields, + ), + ), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'update_many', + }, + objectName: objectMetadata.nameSingular, + operation: 'update_many', + }); + } + + if (permission.canSoftDeleteObjectRecords) { + descriptors.push({ + name: `delete_${snakeSingular}`, + description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`, + category: ToolCategory.DATABASE_CRUD, + inputSchema: z.toJSONSchema(DeleteToolInputSchema), + executionRef: { + kind: 'database_crud', + objectNameSingular: objectMetadata.nameSingular, + operation: 'delete', + }, + objectName: objectMetadata.nameSingular, + operation: 'delete', + }); + } } - return tools; + return descriptors; } private getObjectPermissions( diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts index cf34d173a1f..46af4bd673c 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/logic-function-tool.provider.ts @@ -1,6 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { jsonSchema, type ToolSet } from 'ai'; import { isDefined } from 'twenty-shared/utils'; import { @@ -9,26 +8,25 @@ import { } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; -import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type'; -import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; @Injectable() export class LogicFunctionToolProvider implements ToolProvider { readonly category = ToolCategory.LOGIC_FUNCTION; constructor( - private readonly logicFunctionExecutorService: LogicFunctionExecutorService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, ) {} async isAvailable(_context: ToolProviderContext): Promise { - // Logic function tools are available if there are any functions marked as tools return true; } - async generateTools(context: ToolProviderContext): Promise { + async generateDescriptors( + context: ToolProviderContext, + ): Promise { const { flatLogicFunctionMaps } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { @@ -37,7 +35,6 @@ export class LogicFunctionToolProvider implements ToolProvider { }, ); - // Filter logic functions that are marked as tools const logicFunctionsWithSchema = Object.values( flatLogicFunctionMaps.byUniversalIdentifier, ).filter( @@ -45,49 +42,35 @@ export class LogicFunctionToolProvider implements ToolProvider { isDefined(fn) && fn.isTool === true && fn.deletedAt === null, ); - const tools: ToolSet = {}; + const descriptors: ToolDescriptor[] = []; for (const logicFunction of logicFunctionsWithSchema) { const toolName = this.buildLogicFunctionToolName(logicFunction.name); - const wrappedSchema = wrapJsonSchemaForExecution( - logicFunction.toolInputSchema as Record, - ); + // Logic functions already store JSON Schema -- use it directly + const inputSchema = (logicFunction.toolInputSchema as object) ?? { + type: 'object', + properties: {}, + }; - tools[toolName] = { + descriptors.push({ + name: toolName, description: logicFunction.description || `Execute the ${logicFunction.name} logic function`, - inputSchema: jsonSchema(wrappedSchema), - execute: async (parameters: Record) => { - const { loadingMessage: _, ...actualParams } = parameters; - - const result = await this.logicFunctionExecutorService.execute({ - logicFunctionId: logicFunction.id, - workspaceId: context.workspaceId, - payload: actualParams, - }); - - if (result.error) { - return { - success: false, - error: result.error.errorMessage, - }; - } - - return { - success: true, - result: result.data, - }; + category: ToolCategory.LOGIC_FUNCTION, + inputSchema, + executionRef: { + kind: 'logic_function', + logicFunctionId: logicFunction.id, }, - }; + }); } - return tools; + return descriptors; } private buildLogicFunctionToolName(functionName: string): string { - // Convert function name to a valid tool name (lowercase, underscores) return `logic_function_${functionName .toLowerCase() .replace(/[^a-z0-9]+/g, '_') diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts index 5c6bbeac871..1c5758bae5a 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/metadata-tool.provider.ts @@ -1,6 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleInit } from '@nestjs/common'; -import { type ToolSet } from 'ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { @@ -9,20 +8,37 @@ import { } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory'; import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; @Injectable() -export class MetadataToolProvider implements ToolProvider { +export class MetadataToolProvider implements ToolProvider, OnModuleInit { readonly category = ToolCategory.METADATA; constructor( private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory, private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory, private readonly permissionsService: PermissionsService, + private readonly toolExecutorService: ToolExecutorService, ) {} + onModuleInit(): void { + const objectFactory = this.objectMetadataToolsFactory; + const fieldFactory = this.fieldMetadataToolsFactory; + + this.toolExecutorService.registerCategoryGenerator( + ToolCategory.METADATA, + async (context) => ({ + ...objectFactory.generateTools(context.workspaceId), + ...fieldFactory.generateTools(context.workspaceId), + }), + ); + } + async isAvailable(context: ToolProviderContext): Promise { return this.permissionsService.checkRolesPermissions( context.rolePermissionConfig, @@ -31,10 +47,14 @@ export class MetadataToolProvider implements ToolProvider { ); } - async generateTools(context: ToolProviderContext): Promise { - return { + async generateDescriptors( + context: ToolProviderContext, + ): Promise { + const toolSet = { ...this.objectMetadataToolsFactory.generateTools(context.workspaceId), ...this.fieldMetadataToolsFactory.generateTools(context.workspaceId), }; + + return toolSetToDescriptors(toolSet, ToolCategory.METADATA); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts index f0acea0d7a1..205a2aeabf1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/native-model-tool.provider.ts @@ -4,7 +4,7 @@ import { type ToolSet } from 'ai'; import { isDefined } from 'twenty-shared/utils'; import { - type ToolProvider, + type NativeToolProvider, type ToolProviderContext, } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; @@ -12,8 +12,10 @@ import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-c import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service'; import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service'; +// SDK-native tools (anthropic webSearch, etc.) are opaque and not serializable. +// This provider keeps generateTools() and is excluded from the descriptor system. @Injectable() -export class NativeModelToolProvider implements ToolProvider { +export class NativeModelToolProvider implements NativeToolProvider { readonly category = ToolCategory.NATIVE_MODEL; constructor( diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts index aff410c0c81..2ab5410caf9 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/view-tool.provider.ts @@ -1,6 +1,5 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, OnModuleInit } from '@nestjs/common'; -import { type ToolSet } from 'ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { @@ -9,23 +8,64 @@ import { } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory'; @Injectable() -export class ViewToolProvider implements ToolProvider { +export class ViewToolProvider implements ToolProvider, OnModuleInit { readonly category = ToolCategory.VIEW; constructor( private readonly viewToolsFactory: ViewToolsFactory, private readonly permissionsService: PermissionsService, + private readonly toolExecutorService: ToolExecutorService, ) {} + onModuleInit(): void { + const factory = this.viewToolsFactory; + + this.toolExecutorService.registerCategoryGenerator( + ToolCategory.VIEW, + async (context) => { + const workspaceMemberId = context.actorContext?.workspaceMemberId; + + const readTools = factory.generateReadTools( + context.workspaceId, + workspaceMemberId ?? undefined, + workspaceMemberId ?? undefined, + ); + + const hasViewPermission = + await this.permissionsService.checkRolesPermissions( + context.rolePermissionConfig, + context.workspaceId, + PermissionFlagType.VIEWS, + ); + + if (hasViewPermission) { + const writeTools = factory.generateWriteTools( + context.workspaceId, + workspaceMemberId ?? undefined, + ); + + return { ...readTools, ...writeTools }; + } + + return readTools; + }, + ); + } + async isAvailable(_context: ToolProviderContext): Promise { return true; } - async generateTools(context: ToolProviderContext): Promise { + async generateDescriptors( + context: ToolProviderContext, + ): Promise { const workspaceMemberId = context.actorContext?.workspaceMemberId; const readTools = this.viewToolsFactory.generateReadTools( @@ -47,9 +87,12 @@ export class ViewToolProvider implements ToolProvider { workspaceMemberId ?? undefined, ); - return { ...readTools, ...writeTools }; + return toolSetToDescriptors( + { ...readTools, ...writeTools }, + ToolCategory.VIEW, + ); } - return readTools; + return toolSetToDescriptors(readTools, ToolCategory.VIEW); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts index 9bbd6466795..5751bea6ac4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/workflow-tool.provider.ts @@ -1,6 +1,5 @@ -import { Inject, Injectable, Optional } from '@nestjs/common'; +import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common'; -import { type ToolSet } from 'ai'; import { PermissionFlagType } from 'twenty-shared/constants'; import { @@ -10,11 +9,14 @@ import { import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util'; import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service'; import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service'; @Injectable() -export class WorkflowToolProvider implements ToolProvider { +export class WorkflowToolProvider implements ToolProvider, OnModuleInit { readonly category = ToolCategory.WORKFLOW; constructor( @@ -22,8 +24,24 @@ export class WorkflowToolProvider implements ToolProvider { @Inject(WORKFLOW_TOOL_SERVICE_TOKEN) private readonly workflowToolService: WorkflowToolWorkspaceService | null, private readonly permissionsService: PermissionsService, + private readonly toolExecutorService: ToolExecutorService, ) {} + onModuleInit(): void { + if (this.workflowToolService) { + const service = this.workflowToolService; + + this.toolExecutorService.registerCategoryGenerator( + ToolCategory.WORKFLOW, + async (context) => + service.generateWorkflowTools( + context.workspaceId, + context.rolePermissionConfig, + ), + ); + } + } + async isAvailable(context: ToolProviderContext): Promise { if (!this.workflowToolService) { return false; @@ -36,14 +54,18 @@ export class WorkflowToolProvider implements ToolProvider { ); } - async generateTools(context: ToolProviderContext): Promise { + async generateDescriptors( + context: ToolProviderContext, + ): Promise { if (!this.workflowToolService) { - return {}; + return []; } - return this.workflowToolService.generateWorkflowTools( + const toolSet = await this.workflowToolService.generateWorkflowTools( context.workspaceId, context.rolePermissionConfig, ); + + return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW); } } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts new file mode 100644 index 00000000000..60d443f9d09 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -0,0 +1,310 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { type ToolSet } from 'ai'; +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; + +import { + AuthException, + AuthExceptionCode, +} from 'src/engine/core-modules/auth/auth.exception'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util'; +import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service'; +import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service'; +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 { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service'; +import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service'; +import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type'; +import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; +import { UserEntity } from 'src/engine/core-modules/user/user.entity'; +import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +// Handler for individually registered static tools (e.g., action tools) +export interface StaticToolHandler { + execute(args: ToolInput, context: ToolProviderContext): Promise; +} + +// Generator that produces a ToolSet on demand for a category (workflow, view, etc.) +// Used as a fallback when no per-tool handler is registered. +export type CategoryToolGenerator = ( + context: ToolProviderContext, +) => Promise; + +@Injectable() +export class ToolExecutorService { + private readonly logger = new Logger(ToolExecutorService.name); + + // Per-tool handlers (action tools, etc.) + private readonly staticToolHandlers = new Map(); + + // Category-level ToolSet generators (workflow, view, dashboard, metadata) + private readonly categoryGenerators = new Map< + ToolCategory, + CategoryToolGenerator + >(); + + constructor( + private readonly findRecordsService: FindRecordsService, + private readonly createRecordService: CreateRecordService, + private readonly createManyRecordsService: CreateManyRecordsService, + private readonly updateRecordService: UpdateRecordService, + private readonly updateManyRecordsService: UpdateManyRecordsService, + private readonly deleteRecordService: DeleteRecordService, + private readonly logicFunctionExecutorService: LogicFunctionExecutorService, + private readonly workspaceCacheService: WorkspaceCacheService, + @InjectRepository(UserEntity) + private readonly userRepository: Repository, + ) {} + + registerStaticHandler(toolId: string, handler: StaticToolHandler): void { + this.staticToolHandlers.set(toolId, handler); + } + + registerCategoryGenerator( + category: ToolCategory, + generator: CategoryToolGenerator, + ): void { + this.categoryGenerators.set(category, generator); + } + + async dispatch( + descriptor: ToolDescriptor, + args: Record, + context: ToolProviderContext, + ): Promise { + const cleanArgs = stripLoadingMessage(args); + + switch (descriptor.executionRef.kind) { + case 'database_crud': + return this.dispatchDatabaseCrud( + descriptor.executionRef, + cleanArgs, + context, + ); + case 'static': + return this.dispatchStaticTool(descriptor, cleanArgs, context); + case 'logic_function': + return this.dispatchLogicFunction( + descriptor.executionRef, + cleanArgs, + context, + ); + } + } + + private async dispatchDatabaseCrud( + ref: { objectNameSingular: string; operation: string }, + args: Record, + context: ToolProviderContext, + ): Promise { + const authContext = + context.authContext ?? (await this.buildAuthContext(context)); + + switch (ref.operation) { + case 'find': { + const { limit, offset, orderBy, ...filter } = args; + + return this.findRecordsService.execute({ + objectName: ref.objectNameSingular, + filter, + orderBy: orderBy as never, + limit: limit as number | undefined, + offset: offset as number | undefined, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + }); + } + + case 'find_one': + return this.findRecordsService.execute({ + objectName: ref.objectNameSingular, + filter: { id: { eq: args.id } }, + limit: 1, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + }); + + case 'create': + return this.createRecordService.execute({ + objectName: ref.objectNameSingular, + objectRecord: args, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + createdBy: context.actorContext, + slimResponse: true, + }); + + case 'create_many': + return this.createManyRecordsService.execute({ + objectName: ref.objectNameSingular, + objectRecords: args.records as Record[], + authContext, + rolePermissionConfig: context.rolePermissionConfig, + createdBy: context.actorContext, + slimResponse: true, + }); + + case 'update': { + const { id, ...fields } = args; + const objectRecord = Object.fromEntries( + Object.entries(fields).filter(([, value]) => value !== undefined), + ); + + return this.updateRecordService.execute({ + objectName: ref.objectNameSingular, + objectRecordId: id as string, + objectRecord, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + slimResponse: true, + }); + } + + case 'update_many': + return this.updateManyRecordsService.execute({ + objectName: ref.objectNameSingular, + filter: args.filter as Record, + data: args.data as Record, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + slimResponse: true, + }); + + case 'delete': + return this.deleteRecordService.execute({ + objectName: ref.objectNameSingular, + objectRecordId: args.id as string, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + soft: true, + }); + + default: + throw new Error(`Unknown database_crud operation: ${ref.operation}`); + } + } + + private async dispatchStaticTool( + descriptor: ToolDescriptor, + args: Record, + context: ToolProviderContext, + ): Promise { + if (descriptor.executionRef.kind !== 'static') { + throw new Error('Expected static executionRef'); + } + + // Per-tool handler first (action tools) + const handler = this.staticToolHandlers.get(descriptor.executionRef.toolId); + + if (handler) { + return handler.execute(args, context); + } + + // Category-level generator fallback (workflow, view, dashboard, metadata) + const generator = this.categoryGenerators.get(descriptor.category); + + if (!generator) { + throw new Error( + `No handler or generator for static tool: ${descriptor.executionRef.toolId}`, + ); + } + + const toolSet = await generator(context); + const tool = toolSet[descriptor.name]; + + if (!tool?.execute) { + throw new Error( + `Tool ${descriptor.name} not found in generated ToolSet for category ${descriptor.category}`, + ); + } + + // The tool's execute expects (args, ToolCallOptions). Pass args with + // a dummy loadingMessage since the tool's internal strip is harmless. + return tool.execute( + { loadingMessage: '', ...args }, + { toolCallId: '', messages: [] }, + ); + } + + private async dispatchLogicFunction( + ref: { logicFunctionId: string }, + args: Record, + context: ToolProviderContext, + ): Promise { + const result = await this.logicFunctionExecutorService.execute({ + logicFunctionId: ref.logicFunctionId, + workspaceId: context.workspaceId, + payload: args, + }); + + if (result.error) { + return { + success: false, + error: result.error.errorMessage, + }; + } + + return { + success: true, + result: result.data, + }; + } + + // Build authContext on demand for database CRUD operations + private async buildAuthContext( + context: ToolProviderContext, + ): Promise { + if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) { + throw new AuthException( + 'userId and userWorkspaceId are required for database operations', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + const user = await this.userRepository.findOne({ + where: { id: context.userId }, + }); + + if (!isDefined(user)) { + throw new AuthException( + 'User not found', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + const { flatWorkspaceMemberMaps } = + await this.workspaceCacheService.getOrRecompute(context.workspaceId, [ + 'flatWorkspaceMemberMaps', + ]); + + const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id]; + + const workspaceMember = isDefined(workspaceMemberId) + ? flatWorkspaceMemberMaps.byId[workspaceMemberId] + : undefined; + + if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) { + throw new AuthException( + 'Workspace member not found', + AuthExceptionCode.UNAUTHENTICATED, + ); + } + + return buildUserAuthContext({ + workspace: { id: context.workspaceId } as WorkspaceEntity, + userWorkspaceId: context.userWorkspaceId, + user, + workspaceMemberId, + workspaceMember, + }); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts index 7184922a89a..8409aa394ca 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-registry.service.ts @@ -1,46 +1,34 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; -import { type ToolSet, zodSchema } from 'ai'; +import { type ToolCallOptions, type ToolSet, jsonSchema } from 'ai'; import { type ActorMetadata } from 'twenty-shared/types'; -import { type ZodType } from 'zod'; import { type CodeExecutionStreamEmitter, + type NativeToolProvider, type ToolProvider, type ToolProviderContext, type ToolRetrievalOptions, } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token'; -import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; +import { type ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool'; +import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util'; import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider'; -export type ToolIndexEntry = { - name: string; - description: string; - category: - | 'DATABASE' - | 'ACTION' - | 'WORKFLOW' - | 'METADATA' - | 'VIEW' - | 'DASHBOARD' - | 'LOGIC_FUNCTION'; - objectName?: string; - operation?: string; - inputSchema?: object; -}; +// Backward-compatible alias -- consumers can import this instead of ToolDescriptor +export type ToolIndexEntry = ToolDescriptor; export type ToolSearchOptions = { limit?: number; - category?: - | 'DATABASE' - | 'ACTION' - | 'WORKFLOW' - | 'METADATA' - | 'VIEW' - | 'DASHBOARD' - | 'LOGIC_FUNCTION'; + category?: ToolCategory; }; export type ToolContext = { @@ -52,20 +40,119 @@ export type ToolContext = { onCodeExecutionUpdate?: CodeExecutionStreamEmitter; }; +const RAM_TTL_MS = 5_000; +const REDIS_TTL_MS = 300_000; + @Injectable() export class ToolRegistryService { private readonly logger = new Logger(ToolRegistryService.name); + // Two-tier cache: RAM (5s) → Redis (5min) → generate from providers + private readonly ramCache = new Map< + string, + { descriptors: ToolDescriptor[]; cachedAt: number } + >(); + constructor( @Inject(TOOL_PROVIDERS) private readonly providers: ToolProvider[], + private readonly nativeModelToolProvider: NativeModelToolProvider, + private readonly toolExecutorService: ToolExecutorService, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, ) {} + // Core: returns cached ToolDescriptor[] for a workspace+role+user + async getCatalog(context: ToolProviderContext): Promise { + const cacheKey = await this.buildCacheKey(context); + + // 1. RAM hit? + const ramEntry = this.ramCache.get(cacheKey); + + if (ramEntry && Date.now() - ramEntry.cachedAt < RAM_TTL_MS) { + return ramEntry.descriptors; + } + + // 2. Redis hit? + const redisData = + await this.workspaceCacheStorageService.getToolCatalog(cacheKey); + + if (redisData) { + const descriptors = redisData as ToolDescriptor[]; + + this.ramCache.set(cacheKey, { + descriptors, + cachedAt: Date.now(), + }); + + return descriptors; + } + + // 3. Generate from providers (cache miss) + const descriptors: ToolDescriptor[] = []; + + for (const provider of this.providers) { + if (await provider.isAvailable(context)) { + const providerDescriptors = await provider.generateDescriptors(context); + + descriptors.push(...providerDescriptors); + } + } + + this.logger.log( + `Generated ${descriptors.length} tool descriptors for workspace ${context.workspaceId}`, + ); + + // Store in both caches + this.ramCache.set(cacheKey, { + descriptors, + cachedAt: Date.now(), + }); + + await this.workspaceCacheStorageService.setToolCatalog( + cacheKey, + descriptors, + REDIS_TTL_MS, + ); + + return descriptors; + } + + // Hydrate ToolDescriptor[] into an AI SDK ToolSet with thin dispatch closures + hydrateToolSet( + descriptors: ToolDescriptor[], + context: ToolProviderContext, + options?: { wrapWithErrorContext?: boolean }, + ): ToolSet { + const toolSet: ToolSet = {}; + + for (const descriptor of descriptors) { + // Add loadingMessage to the clean stored schema + const schemaWithLoading = wrapJsonSchemaForExecution( + descriptor.inputSchema as Record, + ); + + const executeFn = async ( + args: Record, + ): Promise => + this.toolExecutorService.dispatch(descriptor, args, context); + + toolSet[descriptor.name] = { + description: descriptor.description, + inputSchema: jsonSchema(schemaWithLoading), + execute: options?.wrapWithErrorContext + ? this.wrapWithErrorHandler(descriptor.name, executeFn) + : executeFn, + }; + } + + return toolSet; + } + async buildToolIndex( workspaceId: string, roleId: string, options?: { userId?: string; userWorkspaceId?: string }, - ): Promise { + ): Promise { const context = this.buildContext( workspaceId, roleId, @@ -73,21 +160,8 @@ export class ToolRegistryService { options?.userId, options?.userWorkspaceId, ); - const entries: ToolIndexEntry[] = []; - for (const provider of this.providers) { - if (await provider.isAvailable(context)) { - const tools = await provider.generateTools(context); - - entries.push(...this.toolSetToIndex(tools, provider.category)); - } - } - - this.logger.log( - `Built tool index with ${entries.length} tools for workspace ${workspaceId}`, - ); - - return entries; + return this.getCatalog(context); } async searchTools( @@ -98,19 +172,24 @@ export class ToolRegistryService { userId?: string; userWorkspaceId?: string; } = {}, - ): Promise { + ): Promise { const { limit = 5, category, userId, userWorkspaceId } = options; - const index = await this.buildToolIndex(workspaceId, roleId, { + const context = this.buildContext( + workspaceId, + roleId, + undefined, userId, userWorkspaceId, - }); + ); + + const descriptors = await this.getCatalog(context); const queryLower = query.toLowerCase(); const queryTerms = queryLower .split(/\s+/) .filter((term) => term.length > 2); - const scored = index + const scored = descriptors .filter((tool) => !category || tool.category === category) .map((tool) => { let score = 0; @@ -171,59 +250,168 @@ export class ToolRegistryService { context.userId, context.userWorkspaceId, ); - const allTools: ToolSet = {}; - for (const provider of this.providers) { - if (await provider.isAvailable(fullContext)) { - const tools = await provider.generateTools(fullContext); - - Object.assign(allTools, tools); - } - } - - return Object.fromEntries( - names - .filter((name) => name in allTools) - .map((name) => [name, allTools[name]]), + const descriptors = await this.getCatalog(fullContext); + const nameSet = new Set(names); + const filtered = descriptors.filter((descriptor) => + nameSet.has(descriptor.name), ); + + return this.hydrateToolSet(filtered, fullContext); } - // Main method for eager loading tools by categories (replaces ToolProviderService.getTools) + async getToolInfo( + names: string[], + context: ToolContext, + aspects: LearnToolsAspect[] = ['description', 'schema'], + ): Promise< + Array<{ name: string; description?: string; inputSchema?: object }> + > { + const fullContext = this.buildContext( + context.workspaceId, + context.roleId, + context.onCodeExecutionUpdate, + context.userId, + context.userWorkspaceId, + ); + + const descriptors = await this.getCatalog(fullContext); + + const nameSet = new Set(names); + const filtered = descriptors.filter((entry) => nameSet.has(entry.name)); + + return filtered.map((entry) => { + const info: { + name: string; + description?: string; + inputSchema?: object; + } = { name: entry.name }; + + if (aspects.includes('description')) { + info.description = entry.description; + } + + if (aspects.includes('schema')) { + info.inputSchema = entry.inputSchema; + } + + return info; + }); + } + + async resolveAndExecute( + toolName: string, + args: Record, + context: ToolContext, + _options: ToolCallOptions, + ): Promise { + try { + const fullContext = this.buildContext( + context.workspaceId, + context.roleId, + context.onCodeExecutionUpdate, + context.userId, + context.userWorkspaceId, + ); + + const descriptors = await this.getCatalog(fullContext); + const descriptor = descriptors.find((desc) => desc.name === toolName); + + if (!descriptor) { + return { + toolName, + error: { + message: `Tool "${toolName}" not found. Check the tool catalog for correct names.`, + suggestion: + 'Use learn_tools to discover available tools and their correct names.', + }, + }; + } + + const result = await this.toolExecutorService.dispatch( + descriptor, + args, + fullContext, + ); + + return { + toolName, + result: compactToolOutput(result), + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + this.logger.error(`Error executing tool "${toolName}": ${errorMessage}`); + + return { + toolName, + error: { + message: errorMessage, + suggestion: this.generateErrorSuggestion(toolName, errorMessage), + }, + }; + } + } + + // Main method for eager loading tools by categories async getToolsByCategories( context: ToolProviderContext, options: ToolRetrievalOptions = {}, ): Promise { const { categories, excludeTools, wrapWithErrorContext } = options; - const tools: ToolSet = {}; + const descriptors = await this.getCatalog(context); - for (const provider of this.providers) { - if (categories && !categories.includes(provider.category)) { - continue; - } - if (await provider.isAvailable(context)) { - const providerTools = await provider.generateTools(context); + let filteredDescriptors: ToolDescriptor[]; - Object.assign(tools, providerTools); - } + if (categories) { + const categorySet = new Set(categories); + + filteredDescriptors = descriptors.filter((descriptor) => + categorySet.has(descriptor.category), + ); + } else { + filteredDescriptors = [...descriptors]; } // Apply excludeTools filter if (excludeTools?.length) { - for (const toolType of excludeTools) { - delete tools[toolType.toLowerCase()]; + const excludeSet = new Set(excludeTools); + + filteredDescriptors = filteredDescriptors.filter( + (descriptor) => !excludeSet.has(descriptor.name), + ); + } + + const toolSet = this.hydrateToolSet(filteredDescriptors, context, { + wrapWithErrorContext, + }); + + // Handle NativeModelToolProvider separately (SDK-opaque tools) + if (categories?.includes(ToolCategory.NATIVE_MODEL)) { + if (await this.nativeModelToolProvider.isAvailable(context)) { + const nativeTools = await ( + this.nativeModelToolProvider as NativeToolProvider + ).generateTools(context); + + Object.assign(toolSet, nativeTools); } } this.logger.log( - `Generated ${Object.keys(tools).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`, + `Generated ${Object.keys(toolSet).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`, ); - // Apply error wrapping if requested - if (wrapWithErrorContext) { - return this.wrapToolsWithErrorContext(tools); - } + return toolSet; + } - return tools; + private async buildCacheKey(context: ToolProviderContext): Promise { + const metadataVersion = + (await this.workspaceCacheStorageService.getMetadataVersion( + context.workspaceId, + )) ?? 0; + + return `${context.workspaceId}:v${metadataVersion}:${context.roleId}:${context.userId ?? 'system'}`; } private buildContext( @@ -247,143 +435,27 @@ export class ToolRegistryService { }; } - private toolSetToIndex( - tools: ToolSet, - category: ToolCategory, - ): ToolIndexEntry[] { - const categoryMap: Record = { - DATABASE_CRUD: 'DATABASE', - ACTION: 'ACTION', - WORKFLOW: 'WORKFLOW', - METADATA: 'METADATA', - NATIVE_MODEL: 'ACTION', - VIEW: 'VIEW', - DASHBOARD: 'DASHBOARD', - LOGIC_FUNCTION: 'LOGIC_FUNCTION', - }; - - return Object.entries(tools).map(([name, tool]) => { - const inputSchema = this.extractJsonSchema(tool.inputSchema); - - return { - name, - description: tool.description ?? '', - category: categoryMap[category], - inputSchema, - }; - }); - } - - private extractJsonSchema(inputSchema: unknown): object | undefined { - if (!inputSchema) { - return undefined; - } - - let schema: object | undefined; - - // Check if it's a Zod schema (has _def property) - if ( - typeof inputSchema === 'object' && - inputSchema !== null && - '_def' in inputSchema - ) { + private wrapWithErrorHandler( + toolName: string, + executeFn: (args: Record) => Promise, + ): (args: Record) => Promise { + return async (args: Record) => { try { - // Use AI SDK's zodSchema() to convert Zod to JSON Schema - const converted = zodSchema(inputSchema as ZodType); + return await executeFn(args); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); - schema = converted.jsonSchema as object; - } catch { - // If conversion fails, return undefined - return undefined; + return { + success: false, + error: { + message: errorMessage, + tool: toolName, + suggestion: this.generateErrorSuggestion(toolName, errorMessage), + }, + }; } - } else if ( - // Check if AI SDK wrapped it with jsonSchema property - typeof inputSchema === 'object' && - inputSchema !== null && - 'jsonSchema' in inputSchema - ) { - schema = (inputSchema as { jsonSchema: object }).jsonSchema; - } else if (typeof inputSchema === 'object') { - // Return as-is if it's already an object (plain JSON schema) - schema = inputSchema as object; - } - - if (!schema) { - return undefined; - } - - return this.stripInternalFieldsFromSchema(schema); - } - - // Remove internal fields (loadingMessage) from schema for display - private stripInternalFieldsFromSchema(schema: object): object { - const schemaObj = schema as Record; - - // Remove $schema property - const { $schema: _, ...rest } = schemaObj; - - // Remove loadingMessage from properties if present - // loadingMessage is an internal field auto-injected for AI status updates - if ( - rest.type === 'object' && - rest.properties && - typeof rest.properties === 'object' - ) { - const properties = rest.properties as Record; - const { loadingMessage: __, ...cleanProperties } = properties; - - // Filter required array to remove loadingMessage if present - const required = Array.isArray(rest.required) - ? rest.required.filter((field) => field !== 'loadingMessage') - : undefined; - - return { - ...rest, - properties: cleanProperties, - ...(required && required.length > 0 ? { required } : {}), - }; - } - - return rest; - } - - private wrapToolsWithErrorContext(tools: ToolSet): ToolSet { - const wrappedTools: ToolSet = {}; - - for (const [toolName, tool] of Object.entries(tools)) { - if (!tool.execute) { - wrappedTools[toolName] = tool; - continue; - } - - const originalExecute = tool.execute; - - wrappedTools[toolName] = { - ...tool, - execute: async (...args: Parameters) => { - try { - return await originalExecute(...args); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - - return { - success: false, - error: { - message: errorMessage, - tool: toolName, - suggestion: this.generateErrorSuggestion( - toolName, - errorMessage, - ), - }, - }; - } - }, - }; - } - - return wrappedTools; + }; } private generateErrorSuggestion( 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 83b2daedb1e..76d6e4ff9e1 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 @@ -12,6 +12,7 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider'; import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider'; import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider'; +import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service'; import { ToolModule } from 'src/engine/core-modules/tool/tool.module'; import { UserEntity } from 'src/engine/core-modules/user/user.entity'; import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module'; @@ -24,6 +25,7 @@ import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/ import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; import { ViewModule } from 'src/engine/metadata-modules/view/view.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { ToolIndexResolver } from './resolvers/tool-index.resolver'; import { ToolRegistryService } from './services/tool-registry.service'; @@ -45,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; PermissionsModule, ViewModule, WorkspaceCacheModule, + WorkspaceCacheStorageModule, WorkspaceManyOrAllFlatEntityMapsCacheModule, LogicFunctionModule, UserRoleModule, @@ -52,6 +55,7 @@ import { ToolRegistryService } from './services/tool-registry.service'; ], providers: [ ToolIndexResolver, + ToolExecutorService, ActionToolProvider, DashboardToolProvider, DatabaseToolProvider, @@ -61,13 +65,14 @@ import { ToolRegistryService } from './services/tool-registry.service'; ViewToolProvider, WorkflowToolProvider, { + // TOOL_PROVIDERS contains only providers implementing ToolProvider (generateDescriptors). + // NativeModelToolProvider is excluded -- it's injected separately in the registry. provide: TOOL_PROVIDERS, useFactory: ( actionProvider: ActionToolProvider, dashboardProvider: DashboardToolProvider, databaseProvider: DatabaseToolProvider, metadataProvider: MetadataToolProvider, - nativeModelProvider: NativeModelToolProvider, logicFunctionProvider: LogicFunctionToolProvider, viewProvider: ViewToolProvider, workflowProvider: WorkflowToolProvider, @@ -76,7 +81,6 @@ import { ToolRegistryService } from './services/tool-registry.service'; dashboardProvider, databaseProvider, metadataProvider, - nativeModelProvider, logicFunctionProvider, viewProvider, workflowProvider, @@ -86,7 +90,6 @@ import { ToolRegistryService } from './services/tool-registry.service'; DashboardToolProvider, DatabaseToolProvider, MetadataToolProvider, - NativeModelToolProvider, LogicFunctionToolProvider, ViewToolProvider, WorkflowToolProvider, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts new file mode 100644 index 00000000000..4b77db5067f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -0,0 +1,57 @@ +import { type ToolCallOptions, type ToolSet } from 'ai'; +import { z } from 'zod'; + +import { + type ToolContext, + type ToolRegistryService, +} from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; + +export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool'; + +export const executeToolInputSchema = z.object({ + toolName: z.string().describe('Exact name of the tool to execute.'), + arguments: z + .record(z.string(), z.unknown()) + .describe( + 'Arguments to pass to the tool. Must match the schema from learn_tools.', + ), +}); + +export type ExecuteToolInput = z.infer; + +export type ExecuteToolResult = { + toolName: string; + result?: unknown; + error?: { + message: string; + suggestion: string; + }; +}; + +export const createExecuteToolTool = ( + toolRegistry: ToolRegistryService, + context: ToolContext, + directTools?: ToolSet, +) => ({ + description: + 'Execute a tool by name. Use learn_tools first to discover the correct schema, then call this with the tool name and arguments.', + inputSchema: executeToolInputSchema, + execute: async ( + parameters: ExecuteToolInput, + options: ToolCallOptions, + ): Promise => { + const { toolName, arguments: args } = parameters; + + // Native provider tools and preloaded tools are already in the ToolSet; + // dispatch directly if the LLM routes them through execute_tool. + const directTool = directTools?.[toolName]; + + if (directTool?.execute) { + const result = await directTool.execute(args, options); + + return { toolName, result }; + } + + return toolRegistry.resolveAndExecute(toolName, args, context, options); + }, +}); 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 index 9eff6640def..ea59445794f 100644 --- 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 @@ -1,11 +1,19 @@ export { - LOAD_TOOLS_TOOL_NAME, - createLoadToolsTool, - loadToolsInputSchema, - type DynamicToolStore, - type LoadToolsInput, - type LoadToolsResult, -} from './load-tools.tool'; + LEARN_TOOLS_TOOL_NAME, + createLearnToolsTool, + learnToolsInputSchema, + type LearnToolsAspect, + type LearnToolsInput, + type LearnToolsResult, +} from './learn-tools.tool'; + +export { + EXECUTE_TOOL_TOOL_NAME, + createExecuteToolTool, + executeToolInputSchema, + type ExecuteToolInput, + type ExecuteToolResult, +} from './execute-tool.tool'; export { LOAD_SKILL_TOOL_NAME, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts new file mode 100644 index 00000000000..9d5ef36d6a6 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -0,0 +1,74 @@ +import { z } from 'zod'; + +import { + type ToolContext, + type ToolRegistryService, +} from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; + +export const LEARN_TOOLS_TOOL_NAME = 'learn_tools'; + +const learnToolsAspectSchema = z.enum(['description', 'schema']); + +export type LearnToolsAspect = z.infer; + +export const learnToolsInputSchema = z.object({ + toolNames: z + .array(z.string()) + .describe( + 'Tool names to learn about. Use exact names from the tool catalog.', + ), + aspects: z + .array(learnToolsAspectSchema) + .optional() + .default(['description', 'schema']) + .describe('What to learn: description, schema, or both.'), +}); + +export type LearnToolsInput = z.infer; + +export type LearnToolsResultEntry = { + name: string; + description?: string; + inputSchema?: object; +}; + +export type LearnToolsResult = { + tools: LearnToolsResultEntry[]; + notFound: string[]; + message: string; +}; + +export const createLearnToolsTool = ( + toolRegistry: ToolRegistryService, + context: ToolContext, +) => ({ + description: + 'Learn about tools before using them. Returns tool descriptions and/or input schemas so you know how to call them via execute_tool.', + inputSchema: learnToolsInputSchema, + execute: async (parameters: LearnToolsInput): Promise => { + const { toolNames, aspects } = parameters; + + const toolInfos = await toolRegistry.getToolInfo( + toolNames, + context, + aspects, + ); + + const foundNames = new Set(toolInfos.map((t) => t.name)); + const notFound = toolNames.filter((name) => !foundNames.has(name)); + + if (notFound.length > 0) { + return { + tools: toolInfos, + notFound, + message: `Learned ${toolInfos.length} tool(s). Could not find: ${notFound.join(', ')}.`, + }; + } + + return { + tools: toolInfos, + notFound: [], + message: `Learned ${toolInfos.length} tool(s): ${toolInfos.map((t) => t.name).join(', ')}.`, + }; + }, +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts index c4499895989..241d87c6686 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-skill.tool.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type'; -export const LOAD_SKILL_TOOL_NAME = 'load_skill'; +export const LOAD_SKILL_TOOL_NAME = 'load_skills'; export const loadSkillInputSchema = z.object({ skillNames: z 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 deleted file mode 100644 index 2b8b41e84ed..00000000000 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/load-tools.tool.ts +++ /dev/null @@ -1,73 +0,0 @@ -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({ - 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; - -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: LoadToolsInput): Promise => { - const { toolNames } = parameters; - - 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-descriptor.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts new file mode 100644 index 00000000000..37d31e91758 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/tool-descriptor.type.ts @@ -0,0 +1,30 @@ +import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; + +export type DatabaseCrudOperation = + | 'find' + | 'find_one' + | 'create' + | 'create_many' + | 'update' + | 'update_many' + | 'delete'; + +export type ToolExecutionRef = + | { + kind: 'database_crud'; + objectNameSingular: string; + operation: DatabaseCrudOperation; + } + | { kind: 'static'; toolId: string } + | { kind: 'logic_function'; logicFunctionId: string }; + +// Fully JSON-serializable tool definition, stored in Redis +export type ToolDescriptor = { + name: string; + description: string; + category: ToolCategory; + inputSchema: object; + executionRef: ToolExecutionRef; + objectName?: string; + operation?: string; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts new file mode 100644 index 00000000000..31ae1b9bdae --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts @@ -0,0 +1,32 @@ +import { type ToolSet } from 'ai'; +import { z } from 'zod'; + +import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; + +// Converts a ToolSet (with Zod schemas and closures) into an array of +// serializable ToolDescriptor objects. Used by providers that delegate to +// existing factory services (workflow, view, dashboard, metadata). +export const toolSetToDescriptors = ( + toolSet: ToolSet, + category: ToolCategory, +): ToolDescriptor[] => { + return Object.entries(toolSet).map(([name, tool]) => { + let inputSchema: object; + + try { + inputSchema = z.toJSONSchema(tool.inputSchema as z.ZodType); + } catch { + // Fallback: schema is already JSON Schema or another format + inputSchema = (tool.inputSchema ?? {}) as object; + } + + return { + name, + description: tool.description ?? '', + category, + inputSchema, + executionRef: { kind: 'static' as const, toolId: name }, + }; + }); +}; diff --git a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts index 06c677bb2f9..ef1c4f61158 100644 --- a/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts +++ b/packages/twenty-server/src/engine/core-modules/twenty-config/config-variables.ts @@ -1262,6 +1262,15 @@ export class ConfigVariables { @IsOptional() XAI_API_KEY: string; + @ConfigVariablesMetadata({ + group: ConfigVariablesGroup.LLM, + isSensitive: true, + description: 'API key for Groq integration', + type: ConfigVariableType.STRING, + }) + @IsOptional() + GROQ_API_KEY: string; + @ConfigVariablesMetadata({ group: ConfigVariablesGroup.SERVER_CONFIG, description: 'Enable or disable multi-workspace support', diff --git a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts index c50b659abc1..16ea0bbdca7 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/dtos/update-workspace-input.ts @@ -117,6 +117,11 @@ export class UpdateWorkspaceInput { @IsOptional() smartModel?: string; + @Field({ nullable: true }) + @IsString() + @IsOptional() + aiAdditionalInstructions?: string; + @Field(() => [String], { nullable: true }) @IsArray() @IsString({ each: true }) diff --git a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts index a05d63e0aa1..0e883fe9dce 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/services/workspace.service.ts @@ -84,6 +84,7 @@ export class WorkspaceService extends TypeOrmQueryService { defaultRoleId: PermissionFlagType.ROLES, fastModel: PermissionFlagType.WORKSPACE, smartModel: PermissionFlagType.WORKSPACE, + aiAdditionalInstructions: PermissionFlagType.WORKSPACE, }; constructor( diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 3e7d7fc13b8..f41a646a1d0 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -296,6 +296,10 @@ export class WorkspaceEntity { @Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL }) smartModel: ModelId; + @Field(() => String, { nullable: true }) + @Column({ type: 'text', nullable: true }) + aiAdditionalInstructions: string | null; + @Column({ nullable: false, type: 'uuid' }) workspaceCustomApplicationId: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts index 4c8e658ddf6..1e61cb08df2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service.ts @@ -12,11 +12,19 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; +export type UserContext = { + firstName: string; + lastName: string; + locale: string; + timezone: string | null; +}; + export type AgentActorContext = { actorContext: ActorMetadata; roleId: string; userId: string; userWorkspaceId: string; + userContext: UserContext; }; @Injectable() @@ -87,11 +95,19 @@ export class AgentActorContextService { workspaceMemberId: workspaceMember.id, }); + const userContext: UserContext = { + firstName: workspaceMember.name?.firstName ?? '', + lastName: workspaceMember.name?.lastName ?? '', + locale: userWorkspace.locale, + timezone: workspaceMember.timeZone ?? null, + }; + return { actorContext, roleId, userId: userWorkspace.userId, userWorkspaceId, + userContext, }; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util.ts new file mode 100644 index 00000000000..8c09b6e05d2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util.ts @@ -0,0 +1,14 @@ +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; + +const FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [ + STANDARD_OBJECTS.favorite.universalIdentifier, + STANDARD_OBJECTS.favoriteFolder.universalIdentifier, +] as const; + +export const isFavoriteRelatedObject = (objectMetadata: { + universalIdentifier: string; +}): boolean => { + return FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.includes( + objectMetadata.universalIdentifier as (typeof FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS)[number], + ); +}; 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 3123d09ae8c..64dbabf247d 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 @@ -29,6 +29,7 @@ import { AgentChatStreamingService } from './services/agent-chat-streaming.servi import { AgentChatService } from './services/agent-chat.service'; import { AgentTitleGenerationService } from './services/agent-title-generation.service'; import { ChatExecutionService } from './services/chat-execution.service'; +import { SystemPromptBuilderService } from './services/system-prompt-builder.service'; @Module({ imports: [ @@ -63,6 +64,7 @@ import { ChatExecutionService } from './services/chat-execution.service'; AgentChatStreamingService, AgentTitleGenerationService, ChatExecutionService, + SystemPromptBuilderService, ], 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 index 1000419f8ff..cb36abd2a07 100644 --- 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 @@ -1,47 +1,54 @@ // 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. + BASE: `You are a helpful AI assistant integrated into Twenty, a CRM (similar to Salesforce). -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 +## Plan → Skill → Learn → Execute + +For ANY non-trivial task, follow this order: + +1. **Plan**: Identify what the user needs. Determine which domain is involved (workflows, dashboards, metadata, data, documents, etc.). +2. **Load the relevant skill FIRST**: Call \`load_skills\` to get detailed instructions, correct schemas, and parameter formats BEFORE doing anything else. Skills contain critical knowledge you don't have built-in — skipping this step leads to incorrect parameters and failed tool calls. +3. **Learn the required tools**: Call \`learn_tools\` to discover tool schemas and descriptions before using them. +4. **Execute**: Call \`execute_tool\` to run the tools following the instructions from the skill. + +⚠️ NEVER call a specialized tool (workflow, dashboard, metadata, etc.) without loading its matching skill first. The Available Skills section below lists all skills — look for the one that matches the user's task domain and load it. + +Examples: +- User asks to create a workflow → \`load_skills(["workflow-building"])\` then learn and execute workflow tools +- User asks to build a dashboard → \`load_skills(["dashboard-building"])\` then learn and execute dashboard tools +- User asks to export data to Excel → \`load_skills(["xlsx", "code-interpreter"])\` then \`learn_tools({toolNames: ["code_interpreter"]})\` then \`execute_tool({toolName: "code_interpreter", arguments: {...}})\` + +For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it. + +## Skills vs Tools + +- **SKILLS** = documentation/instructions (loaded via \`load_skills\`). They teach you HOW to do something — correct schemas, parameters, and patterns. They do NOT give you execution ability. +- **TOOLS** = execution capabilities via \`execute_tool\`. They let you DO something. Use \`learn_tools\` to discover the correct parameters first. +- You need BOTH: skill for knowledge, \`execute_tool\` for action. + +## Database vs HTTP Tools -Database vs HTTP tools: - Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations -- NEVER guess or construct API URLs - always use the appropriate database tool +- NEVER guess or construct API URLs — always use the appropriate database tool - The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data) -- If you need to look up a record, load and use the corresponding find_one_* or find_many_* tool +- If you need to look up a record, learn and execute the corresponding find_one_* or find_many_* tool -Error recovery: -- Analyze error messages to understand what went wrong -- Adjust parameters or try different tools -- Only give up after exhausting reasonable alternatives +## Data Efficiency -Permissions: -- Only perform actions your role allows -- Explain limitations if you lack permissions +- Use small limits (5-10 records) for initial exploration. Only increase if the user explicitly needs more. +- Always apply filters to narrow results — don't fetch all records of a type. +- Fetch one type of data at a time and check if you have what you need before fetching more. +- Every record returned consumes context. Fetching too many records at once will cause failures. -Skills vs Tools: -- SKILLS = documentation/instructions (loaded via \`load_skill\`). They teach you HOW to do something. -- TOOLS = execution capabilities (loaded via \`load_tools\`). They let you DO something. -- Skills don't give you abilities - they give you knowledge. You still need the tool to act. +## Tool Strategy -Python Code Execution: -- To run Python code, you need TWO things: - 1. Load the skill for instructions: \`load_skill(["code-interpreter"])\` - 2. Load the tool for execution: \`load_tools(["code_interpreter"])\` -- Then call \`code_interpreter\` with your Python code -- The Python environment includes a \`twenty\` helper to call any Twenty tool directly from code - -Document Processing (Excel, PDF, Word, PowerPoint): -- For document tasks, load both the skill AND the code_interpreter tool: - 1. \`load_skill(["xlsx"])\` or \`load_skill(["pdf"])\` etc. - gets you detailed instructions - 2. \`load_tools(["code_interpreter"])\` - enables code execution -- Then use \`code_interpreter\` to run the Python code described in the skill`, +- Chain multiple tools to solve complex tasks +- Use results from one tool to inform the next +- If a tool fails, analyze the error, adjust parameters, and try again +- Don't give up after first failure — be persistent and try alternative approaches +- Validate assumptions before making changes +`, // Response formatting and record references RESPONSE_FORMAT: ` diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts index 300a8fd1e2d..2f3201026ac 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto.ts @@ -1,4 +1,4 @@ -import { Field, Int, ObjectType } from '@nestjs/graphql'; +import { Field, Float, Int, ObjectType } from '@nestjs/graphql'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; @@ -20,9 +20,14 @@ export class AgentChatThreadDTO { contextWindowTokens: number | null; @Field(() => Int) + conversationSize: number; + + // Credits are converted from internal precision to display precision + // (internal / 1000) at the resolver level + @Field(() => Float) totalInputCredits: number; - @Field(() => Int) + @Field(() => Float) totalOutputCredits: number; @Field() diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto.ts new file mode 100644 index 00000000000..fc2953d903c --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto.ts @@ -0,0 +1,22 @@ +import { Field, Int, ObjectType } from '@nestjs/graphql'; + +@ObjectType('AISystemPromptSection') +export class AISystemPromptSectionDTO { + @Field(() => String) + title: string; + + @Field(() => String) + content: string; + + @Field(() => Int) + estimatedTokenCount: number; +} + +@ObjectType('AISystemPromptPreview') +export class AISystemPromptPreviewDTO { + @Field(() => [AISystemPromptSectionDTO]) + sections: AISystemPromptSectionDTO[]; + + @Field(() => Int) + estimatedTokenCount: number; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts index a6ec041813e..415489aca70 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity.ts @@ -42,6 +42,9 @@ export class AgentChatThreadEntity { @Column({ type: 'int', nullable: true }) contextWindowTokens: number | null; + @Column({ type: 'int', default: 0 }) + conversationSize: number; + @Column({ type: 'bigint', default: 0 }) totalInputCredits: number; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts index cf657bb95fc..5c058f0f6ee 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/resolvers/agent-chat.resolver.ts @@ -1,11 +1,22 @@ import { UseGuards } from '@nestjs/common'; -import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; +import { + Args, + Float, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +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 { FeatureFlagGuard, RequireFeatureFlag, @@ -14,16 +25,22 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto'; import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto'; +import { AISystemPromptPreviewDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto'; +import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service'; +import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; @UseGuards( WorkspaceAuthGuard, FeatureFlagGuard, SettingsPermissionGuard(PermissionFlagType.AI), ) -@Resolver() +@Resolver(() => AgentChatThreadDTO) export class AgentChatResolver { - constructor(private readonly agentChatService: AgentChatService) {} + constructor( + private readonly agentChatService: AgentChatService, + private readonly systemPromptBuilderService: SystemPromptBuilderService, + ) {} @Query(() => [AgentChatThreadDTO]) @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) @@ -57,4 +74,27 @@ export class AgentChatResolver { async createChatThread(@AuthUserWorkspaceId() userWorkspaceId: string) { return this.agentChatService.createThread(userWorkspaceId); } + + @Query(() => AISystemPromptPreviewDTO) + @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) + async getAISystemPromptPreview( + @AuthWorkspace() workspace: WorkspaceEntity, + @AuthUserWorkspaceId() userWorkspaceId: string, + ) { + return this.systemPromptBuilderService.buildPreview( + workspace.id, + userWorkspaceId, + workspace.aiAdditionalInstructions ?? undefined, + ); + } + + @ResolveField(() => Float) + totalInputCredits(@Parent() thread: AgentChatThreadEntity): number { + return toDisplayCredits(thread.totalInputCredits); + } + + @ResolveField(() => Float) + totalOutputCredits(@Parent() thread: AgentChatThreadEntity): number { + return toDisplayCredits(thread.totalOutputCredits); + } } 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 5aab040414f..c66035733b4 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 @@ -17,6 +17,7 @@ import { } from 'src/engine/metadata-modules/ai/ai-agent/agent.exception'; import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type'; import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util'; +import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util'; import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity'; import { AgentChatService } from './agent-chat.service'; @@ -84,21 +85,14 @@ export class AgentChatStreamingService { onCodeExecutionUpdate, }); - writer.write({ - type: 'data-routing-status' as const, - id: 'execution-status', - data: { - text: 'Processing your request...', - state: 'loading', - }, - }); - let streamUsage = { inputTokens: 0, outputTokens: 0, inputCredits: 0, outputCredits: 0, }; + let lastStepConversationSize = 0; + let totalCacheCreationTokens = 0; writer.merge( stream.toUIMessageStream({ @@ -109,9 +103,37 @@ export class AgentChatStreamingService { }, sendStart: false, messageMetadata: ({ part }) => { + if (part.type === 'finish-step') { + const stepInput = part.usage?.inputTokens ?? 0; + const stepCached = part.usage?.cachedInputTokens ?? 0; + + // Anthropic excludes cached/created tokens from input_tokens, + // reporting them separately as cache_creation_input_tokens + const anthropicUsage = ( + part as { + providerMetadata?: { + anthropic?: { + usage?: { cache_creation_input_tokens?: number }; + }; + }; + } + ).providerMetadata?.anthropic?.usage; + const stepCacheCreation = + anthropicUsage?.cache_creation_input_tokens ?? 0; + + totalCacheCreationTokens += stepCacheCreation; + lastStepConversationSize = + stepInput + stepCached + stepCacheCreation; + } + if (part.type === 'finish') { - const inputTokens = part.totalUsage?.inputTokens ?? 0; + const inputTokens = + (part.totalUsage?.inputTokens ?? 0) + + (part.totalUsage?.cachedInputTokens ?? 0) + + totalCacheCreationTokens; const outputTokens = part.totalUsage?.outputTokens ?? 0; + const cachedInputTokens = + part.totalUsage?.cachedInputTokens ?? 0; const inputCostInCents = (inputTokens / 1000) * @@ -139,8 +161,10 @@ export class AgentChatStreamingService { usage: { inputTokens, outputTokens, - inputCredits, - outputCredits, + cachedInputTokens, + inputCredits: toDisplayCredits(inputCredits), + outputCredits: toDisplayCredits(outputCredits), + conversationSize: lastStepConversationSize, }, model: { contextWindowTokens: modelConfig.contextWindowTokens, @@ -155,15 +179,6 @@ export class AgentChatStreamingService { return; } - writer.write({ - type: 'data-routing-status' as const, - id: 'execution-status', - data: { - text: 'Completed', - state: 'routed', - }, - }); - const validThreadId = thread.id; if (!validThreadId) { @@ -205,6 +220,7 @@ export class AgentChatStreamingService { totalOutputCredits: () => `"totalOutputCredits" + ${streamUsage.outputCredits}`, contextWindowTokens: modelConfig.contextWindowTokens, + conversationSize: lastStepConversationSize, }); } catch (saveError) { this.logger.error( diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts index 9f81be30bf7..3d2f2db1ec4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/chat-execution.service.ts @@ -1,11 +1,13 @@ import { Injectable, Logger } from '@nestjs/common'; import { anthropic } from '@ai-sdk/anthropic'; +import { groq } from '@ai-sdk/groq'; import { openai } from '@ai-sdk/openai'; import { convertToModelMessages, stepCountIs, streamText, + type SystemModelMessage, type ToolSet, type UIDataTypes, type UIMessage, @@ -17,16 +19,16 @@ import { getAppPath } from 'twenty-shared/utils'; import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface'; import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service'; +import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; +import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util'; +import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; import { - type ToolIndexEntry, - ToolRegistryService, -} from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; -import { + createExecuteToolTool, + createLearnToolsTool, createLoadSkillTool, - createLoadToolsTool, - type DynamicToolStore, + EXECUTE_TOOL_TOOL_NAME, + LEARN_TOOLS_TOOL_NAME, LOAD_SKILL_TOOL_NAME, - 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'; @@ -34,7 +36,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/ import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.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 { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service'; import { extractCodeInterpreterFiles, type ExtractedFile, @@ -45,7 +47,6 @@ import { } 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 { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type'; import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; export type ChatExecutionOptions = { @@ -58,12 +59,9 @@ export type ChatExecutionOptions = { export type ChatExecutionResult = { stream: ReturnType; - preloadedTools: string[]; modelConfig: AIModelConfig; }; -const COMMON_PRELOAD_TOOLS = ['search_help_center']; - @Injectable() export class ChatExecutionService { private readonly logger = new Logger(ChatExecutionService.name); @@ -75,6 +73,7 @@ export class ChatExecutionService { private readonly aiBillingService: AIBillingService, private readonly agentActorContextService: AgentActorContextService, private readonly workspaceDomainsService: WorkspaceDomainsService, + private readonly systemPromptBuilder: SystemPromptBuilderService, ) {} async streamChat({ @@ -84,7 +83,7 @@ export class ChatExecutionService { browsingContext, onCodeExecutionUpdate, }: ChatExecutionOptions): Promise { - const { actorContext, roleId, userId } = + const { actorContext, roleId, userId, userContext } = await this.agentActorContextService.buildUserAndAgentActorContext( userWorkspaceId, workspace.id, @@ -124,33 +123,35 @@ export class ChatExecutionService { const preloadedToolNames = Object.keys(preloadedTools); - const dynamicToolStore: DynamicToolStore = { - loadedTools: new Set(preloadedToolNames), - }; - + // Respect the workspace's model preference (Settings > AI > Model Router) const registeredModel = - this.aiModelRegistryService.getDefaultPerformanceModel(); + await this.aiModelRegistryService.resolveModelForAgent({ + modelId: workspace.smartModel, + }); const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig( registeredModel.modelId, ); - const activeTools: ToolSet = { - ...preloadedTools, + // Direct tools: native provider tools + preloaded tools. + // These are callable directly AND as fallback through execute_tool. + const directTools: ToolSet = { + ...wrapToolsWithOutputSerialization(preloadedTools), ...this.getNativeWebSearchTool(registeredModel.provider), - [LOAD_TOOLS_TOOL_NAME]: createLoadToolsTool( + }; + + // ToolSet is constant for the entire conversation — no mutation. + // learn_tools returns schemas as text; execute_tool dispatches to cached tools. + const activeTools: ToolSet = { + ...directTools, + [LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool( 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(', ')}`); - }, + ), + [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool( + this.toolRegistry, + toolContext, + directTools, ), [LOAD_SKILL_TOOL_NAME]: createLoadSkillTool((skillNames) => this.skillService.findFlatSkillsByNames(skillNames, workspace.id), @@ -173,22 +174,32 @@ export class ChatExecutionService { ); } - const systemPrompt = this.buildSystemPrompt( + const systemPrompt = this.systemPromptBuilder.buildFullPrompt( toolCatalog, skillCatalog, preloadedToolNames, contextString, storedFiles, + workspace.aiAdditionalInstructions ?? undefined, + userContext, ); this.logger.log( `Starting chat execution with model ${registeredModel.modelId}, ${Object.keys(activeTools).length} active tools`, ); + const systemMessage: SystemModelMessage = { + role: 'system', + content: systemPrompt, + providerOptions: + registeredModel.provider === ModelProvider.ANTHROPIC + ? { anthropic: { cacheControl: { type: 'ephemeral' } } } + : undefined, + }; + const stream = streamText({ model: registeredModel.model, - system: systemPrompt, - messages: convertToModelMessages(processedMessages), + messages: [systemMessage, ...convertToModelMessages(processedMessages)], tools: activeTools, stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS), experimental_telemetry: AI_TELEMETRY_CONFIG, @@ -223,7 +234,6 @@ export class ChatExecutionService { return { stream, - preloadedTools: preloadedToolNames, modelConfig, }; } @@ -284,176 +294,18 @@ export class ChatExecutionService { return context; } - private buildSystemPrompt( - toolCatalog: ToolIndexEntry[], - skillCatalog: FlatSkill[], - preloadedTools: string[], - contextString?: string, - storedFiles?: Array<{ filename: string; storagePath: string; url: string }>, - ): string { - const parts: string[] = [ - CHAT_SYSTEM_PROMPTS.BASE, - CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT, - ]; - - parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools)); - parts.push(this.buildSkillCatalogSection(skillCatalog)); - - if (storedFiles && storedFiles.length > 0) { - parts.push(this.buildUploadedFilesSection(storedFiles)); - } - - if (contextString) { - parts.push( - `\nCONTEXT (what the user is currently viewing):\n${contextString}`, - ); - } - - return parts.join('\n'); - } - - private buildUploadedFilesSection( - storedFiles: Array<{ filename: string; storagePath: string; url: string }>, - ): string { - const fileList = storedFiles.map((f) => `- ${f.filename}`).join('\n'); - - const filesJson = JSON.stringify( - storedFiles.map((f) => ({ filename: f.filename, url: f.url })), - ); - - return ` -## Uploaded Files - -The user has uploaded the following files: -${fileList} - -**IMPORTANT**: Use the \`code_interpreter\` tool to analyze these files. -When calling code_interpreter, include the files parameter with these values: -\`\`\`json -${filesJson} -\`\`\` - -In your Python code, access files at \`/home/user/{filename}\`.`; - } - - private buildSkillCatalogSection(skillCatalog: FlatSkill[]): string { - if (skillCatalog.length === 0) { - return ''; - } - - const skillsList = skillCatalog - .map( - (skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`, - ) - .join('\n'); - - return ` -## Available Skills - -Skills provide detailed expertise for specialized tasks. Load a skill before attempting complex operations. -To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s). - -${skillsList}`; - } - - 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', - 'DASHBOARD', - 'METADATA', - 'VIEW', - 'LOGIC_FUNCTION', - ]; - - 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`); - - 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)'; - case 'VIEW': - return 'View Tools (query views)'; - case 'DASHBOARD': - return 'Dashboard Tools (create/manage dashboards)'; - case 'LOGIC_FUNCTION': - return 'Logic Functions (custom tools)'; - 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() }; + case ModelProvider.GROQ: + // Type assertion needed due to @ai-sdk/groq tool type mismatch + return { + web_search: groq.tools.browserSearch({}) as ToolSet[string], + }; default: - // Other providers don't have native web search return {}; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts new file mode 100644 index 00000000000..e7ca633d851 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -0,0 +1,333 @@ +import { Injectable } from '@nestjs/common'; + +import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const'; +import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum'; +import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service'; +import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type'; +import { + EXECUTE_TOOL_TOOL_NAME, + LEARN_TOOLS_TOOL_NAME, + LOAD_SKILL_TOOL_NAME, +} from 'src/engine/core-modules/tool-provider/tools'; +import { + AgentActorContextService, + type UserContext, +} from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service'; +import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const'; +import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type'; +import { SkillService } from 'src/engine/metadata-modules/skill/skill.service'; + +export type SystemPromptSection = { + title: string; + content: string; + estimatedTokenCount: number; +}; + +export type SystemPromptPreview = { + sections: SystemPromptSection[]; + estimatedTokenCount: number; +}; + +// ~4 characters per token for mixed English/code content +const estimateTokenCount = (text: string): number => Math.ceil(text.length / 4); + +@Injectable() +export class SystemPromptBuilderService { + constructor( + private readonly toolRegistry: ToolRegistryService, + private readonly skillService: SkillService, + private readonly agentActorContextService: AgentActorContextService, + ) {} + + async buildPreview( + workspaceId: string, + userWorkspaceId: string, + workspaceInstructions?: string, + ): Promise { + const { roleId, userId, userContext } = + await this.agentActorContextService.buildUserAndAgentActorContext( + userWorkspaceId, + workspaceId, + ); + + const toolCatalog = await this.toolRegistry.buildToolIndex( + workspaceId, + roleId, + { userId, userWorkspaceId }, + ); + + const skillCatalog = await this.skillService.findAllFlatSkills(workspaceId); + + const sections: SystemPromptSection[] = []; + + const baseContent = CHAT_SYSTEM_PROMPTS.BASE; + + sections.push({ + title: 'Base Instructions', + content: baseContent, + estimatedTokenCount: estimateTokenCount(baseContent), + }); + + const responseFormatContent = CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT; + + sections.push({ + title: 'Response Format', + content: responseFormatContent, + estimatedTokenCount: estimateTokenCount(responseFormatContent), + }); + + if (workspaceInstructions) { + const workspaceSection = this.buildWorkspaceInstructionsSection( + workspaceInstructions, + ); + + sections.push({ + title: 'Workspace Instructions', + content: workspaceSection, + estimatedTokenCount: estimateTokenCount(workspaceSection), + }); + } + + if (userContext) { + const userSection = this.buildUserContextSection(userContext); + + sections.push({ + title: 'User Context', + content: userSection, + estimatedTokenCount: estimateTokenCount(userSection), + }); + } + + const toolSection = this.buildToolCatalogSection( + toolCatalog, + COMMON_PRELOAD_TOOLS, + ); + + sections.push({ + title: 'Tool Catalog', + content: toolSection, + estimatedTokenCount: estimateTokenCount(toolSection), + }); + + const skillSection = this.buildSkillCatalogSection(skillCatalog); + + if (skillSection) { + sections.push({ + title: 'Skill Catalog', + content: skillSection, + estimatedTokenCount: estimateTokenCount(skillSection), + }); + } + + const totalTokens = sections.reduce( + (sum, section) => sum + section.estimatedTokenCount, + 0, + ); + + return { + sections, + estimatedTokenCount: totalTokens, + }; + } + + buildFullPrompt( + toolCatalog: ToolDescriptor[], + skillCatalog: FlatSkill[], + preloadedTools: string[], + contextString?: string, + storedFiles?: Array<{ + filename: string; + storagePath: string; + url: string; + }>, + workspaceInstructions?: string, + userContext?: UserContext, + ): string { + const parts: string[] = [ + CHAT_SYSTEM_PROMPTS.BASE, + CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT, + ]; + + if (workspaceInstructions) { + parts.push(this.buildWorkspaceInstructionsSection(workspaceInstructions)); + } + + if (userContext) { + parts.push(this.buildUserContextSection(userContext)); + } + + parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools)); + parts.push(this.buildSkillCatalogSection(skillCatalog)); + + if (storedFiles && storedFiles.length > 0) { + parts.push(this.buildUploadedFilesSection(storedFiles)); + } + + if (contextString) { + parts.push( + `\nCONTEXT (what the user is currently viewing):\n${contextString}`, + ); + } + + return parts.join('\n'); + } + + buildWorkspaceInstructionsSection(instructions: string): string { + return ` +## Workspace Instructions + +The following are custom instructions provided by the workspace administrator: + +${instructions}`; + } + + buildUserContextSection(userContext: UserContext): string { + const parts = [ + `User: ${userContext.firstName} ${userContext.lastName}`.trim(), + `Locale: ${userContext.locale}`, + ]; + + if (userContext.timezone) { + parts.push(`Timezone: ${userContext.timezone}`); + } + + return ` +## User Context + +${parts.join('\n')}`; + } + + buildUploadedFilesSection( + storedFiles: Array<{ filename: string; storagePath: string; url: string }>, + ): string { + const fileList = storedFiles.map((f) => `- ${f.filename}`).join('\n'); + + const filesJson = JSON.stringify( + storedFiles.map((f) => ({ filename: f.filename, url: f.url })), + ); + + return ` +## Uploaded Files + +The user has uploaded the following files: +${fileList} + +**IMPORTANT**: Use the \`code_interpreter\` tool to analyze these files. +When calling code_interpreter, include the files parameter with these values: +\`\`\`json +${filesJson} +\`\`\` + +In your Python code, access files at \`/home/user/{filename}\`.`; + } + + buildSkillCatalogSection(skillCatalog: FlatSkill[]): string { + if (skillCatalog.length === 0) { + return ''; + } + + const skillsList = skillCatalog + .map( + (skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`, + ) + .join('\n'); + + return ` +## Available Skills + +Skills provide detailed expertise for specialized tasks. Load a skill before attempting complex operations. +To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s). + +${skillsList}`; + } + + buildToolCatalogSection( + toolCatalog: ToolDescriptor[], + 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 any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it. + +### 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((toolName) => `- \`${toolName}\` ✓`).join('\n') : ''} + +### Tool Catalog by Category`); + + const categoryOrder = [ + ToolCategory.DATABASE_CRUD, + ToolCategory.ACTION, + ToolCategory.WORKFLOW, + ToolCategory.DASHBOARD, + ToolCategory.METADATA, + ToolCategory.VIEW, + ToolCategory.LOGIC_FUNCTION, + ]; + + 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((tool) => { + const status = preloadedSet.has(tool.name) ? ' ✓' : ''; + + return `- \`${tool.name}\`${status}`; + }) + .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 \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`); + + return sections.join('\n'); + } + + private getCategoryLabel(category: string): string { + switch (category) { + case ToolCategory.DATABASE_CRUD: + return 'Database Tools (CRUD operations)'; + case ToolCategory.ACTION: + return 'Action Tools (HTTP, Email, etc.)'; + case ToolCategory.WORKFLOW: + return 'Workflow Tools (create/manage workflows)'; + case ToolCategory.METADATA: + return 'Metadata Tools (schema management)'; + case ToolCategory.VIEW: + return 'View Tools (query views)'; + case ToolCategory.DASHBOARD: + return 'Dashboard Tools (create/manage dashboards)'; + case ToolCategory.LOGIC_FUNCTION: + return 'Logic Functions (custom tools)'; + default: + return category; + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.ts index f6f5340cd90..75d570e6af6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models-types.const.ts @@ -4,6 +4,7 @@ export enum ModelProvider { ANTHROPIC = 'anthropic', OPENAI_COMPATIBLE = 'open_ai_compatible', XAI = 'xai', + GROQ = 'groq', } export const DEFAULT_FAST_MODEL = 'default-fast-model' as const; @@ -32,6 +33,8 @@ export type ModelId = | 'grok-3-mini' | 'grok-4' | 'grok-4-1-fast-reasoning' + // Groq models + | 'openai/gpt-oss-120b' | string; // Allow custom model names export type SupportedFileType = diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts index 1e74c18bd7a..4add3d18b62 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.spec.ts @@ -15,6 +15,7 @@ describe('AI_MODELS', () => { ModelProvider.OPENAI, ModelProvider.ANTHROPIC, ModelProvider.XAI, + ModelProvider.GROQ, ]; providers.forEach((provider) => { @@ -51,6 +52,7 @@ describe('AI_MODELS', () => { ModelProvider.OPENAI, ModelProvider.ANTHROPIC, ModelProvider.XAI, + ModelProvider.GROQ, ]; providers.forEach((provider) => { @@ -89,7 +91,7 @@ describe('AiModelRegistryService', () => { MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o'); expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow( - 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).', + 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).', ); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.ts index d4966d128e2..cabc007edd5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/ai-models.const.ts @@ -9,6 +9,7 @@ export { import { type AIModelConfig } from './ai-models-types.const'; import { ANTHROPIC_MODELS } from './anthropic-models.const'; +import { GROQ_MODELS } from './groq-models.const'; import { OPENAI_MODELS } from './openai-models.const'; import { XAI_MODELS } from './xai-models.const'; @@ -16,4 +17,5 @@ export const AI_MODELS: AIModelConfig[] = [ ...OPENAI_MODELS, ...ANTHROPIC_MODELS, ...XAI_MODELS, + ...GROQ_MODELS, ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/groq-models.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/groq-models.const.ts new file mode 100644 index 00000000000..5c1dfba5298 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/constants/groq-models.const.ts @@ -0,0 +1,15 @@ +import { type AIModelConfig, ModelProvider } from './ai-models-types.const'; + +export const GROQ_MODELS: AIModelConfig[] = [ + { + modelId: 'openai/gpt-oss-120b', + label: 'GPT-OSS 120B (Groq)', + description: + 'Large-scale open-source model with browser search, served via Groq inference', + provider: ModelProvider.GROQ, + inputCostPer1kTokensInCents: 0.059, + outputCostPer1kTokensInCents: 0.079, + contextWindowTokens: 128000, + maxOutputTokens: 16384, + }, +]; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts index cfc445e78fd..0db04e09def 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { anthropic } from '@ai-sdk/anthropic'; +import { groq } from '@ai-sdk/groq'; import { createOpenAI, openai } from '@ai-sdk/openai'; import { xai } from '@ai-sdk/xai'; import { type LanguageModel } from 'ai'; @@ -18,6 +19,7 @@ import { type AIModelConfig, } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const'; import { ANTHROPIC_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/anthropic-models.const'; +import { GROQ_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/groq-models.const'; import { OPENAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/openai-models.const'; import { XAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/xai-models.const'; @@ -57,6 +59,12 @@ export class AiModelRegistryService { this.registerXaiModels(); } + const groqApiKey = this.twentyConfigService.get('GROQ_API_KEY'); + + if (groqApiKey) { + this.registerGroqModels(); + } + const openaiCompatibleBaseUrl = this.twentyConfigService.get( 'OPENAI_COMPATIBLE_BASE_URL', ); @@ -105,6 +113,17 @@ export class AiModelRegistryService { }); } + private registerGroqModels(): void { + GROQ_MODELS.forEach((modelConfig) => { + this.modelRegistry.set(modelConfig.modelId, { + modelId: modelConfig.modelId, + provider: ModelProvider.GROQ, + model: groq(modelConfig.modelId), + doesSupportThinking: modelConfig.doesSupportThinking, + }); + }); + } + private registerOpenAICompatibleModels( baseUrl: string, modelNamesString: string, @@ -170,7 +189,7 @@ export class AiModelRegistryService { if (!model) { throw new AgentException( - 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).', + 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).', AgentExceptionCode.API_KEY_NOT_CONFIGURED, ); } @@ -192,7 +211,7 @@ export class AiModelRegistryService { if (!model) { throw new AgentException( - 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).', + 'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).', AgentExceptionCode.API_KEY_NOT_CONFIGURED, ); } @@ -290,6 +309,9 @@ export class AiModelRegistryService { case ModelProvider.XAI: apiKey = this.twentyConfigService.get('XAI_API_KEY'); break; + case ModelProvider.GROQ: + apiKey = this.twentyConfigService.get('GROQ_API_KEY'); + break; case ModelProvider.OPENAI_COMPATIBLE: apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY'); break; diff --git a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts index b6bbdfbb45e..c9e5343376e 100644 --- a/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts +++ b/packages/twenty-server/src/engine/workspace-cache-storage/workspace-cache-storage.service.ts @@ -17,6 +17,7 @@ export const METADATA_VERSIONED_WORKSPACE_CACHE_KEY = { MetadataObjectMetadataMaps: 'metadata:object-metadata-maps', GraphQLUsedScalarNames: 'graphql:used-scalar-names', ORMEntitySchemas: 'orm:entity-schemas', + ToolCatalog: 'tool-catalog', } as const; export const WORKSPACE_CACHE_KEYS = { GraphQLOperations: 'graphql:operations', @@ -202,6 +203,24 @@ export class WorkspaceCacheStorageService { ); } + setToolCatalog( + cacheKey: string, + descriptors: unknown[], + ttl: number, + ): Promise { + return this.cacheStorageService.set( + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`, + descriptors, + ttl, + ); + } + + getToolCatalog(cacheKey: string): Promise { + return this.cacheStorageService.get( + `${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`, + ); + } + async flush(workspaceId: string, metadataVersion?: number): Promise { await this.flushVersionedMetadata(workspaceId, metadataVersion); diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts deleted file mode 100644 index 951446402e8..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/create-record-step.builder.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { type ToolSet } from 'ai'; -import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; -import { z } from 'zod'; - -import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; -import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; -import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; - -import { - createAndConfigureStep, - type WorkflowStepToolsDeps, -} from './step-builder.utils'; - -export function buildCreateRecordStepTool( - deps: WorkflowStepToolsDeps, - objectMetadata: ObjectMetadataForToolSchema, - restrictedFields: RestrictedFieldsPermissions, - context: ToolGeneratorContext, -): ToolSet { - const recordPropertiesSchema = generateRecordPropertiesZodSchema( - objectMetadata, - false, - restrictedFields, - ); - - const inputSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to add the step to'), - parentStepId: z - .string() - .optional() - .describe('Optional ID of the parent step this step should come after'), - stepName: z - .string() - .optional() - .describe( - `Name for this step (default: "Create ${objectMetadata.labelSingular}")`, - ), - input: recordPropertiesSchema.describe( - `The ${objectMetadata.labelSingular} record data. Use {{trigger.fieldName}} or {{stepId.fieldName}} syntax to reference dynamic values from previous steps.`, - ), - }); - - return { - [`configure_create_${objectMetadata.nameSingular}_step`]: { - description: - `Add a workflow step that creates a ${objectMetadata.labelSingular} record. ` + - `Provide the record fields directly - use {{trigger.fieldName}} or {{stepId.fieldName}} to reference values from previous steps.`, - inputSchema, - execute: async (parameters: z.infer) => { - try { - const { stepId, result } = await createAndConfigureStep( - deps, - context.workspaceId, - parameters.workflowVersionId, - WorkflowActionType.CREATE_RECORD, - parameters.parentStepId, - { - name: - parameters.stepName || `Create ${objectMetadata.labelSingular}`, - type: WorkflowActionType.CREATE_RECORD, - valid: true, - settings: { - input: { - objectName: objectMetadata.nameSingular, - objectRecord: parameters.input, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: false }, - continueOnFailure: { value: false }, - }, - }, - }, - ); - - return { - success: true, - message: `Created workflow step to create ${objectMetadata.labelSingular}`, - result: { stepId, step: result }, - }; - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create ${objectMetadata.labelSingular} workflow step: ${error.message}`, - }; - } - }, - }, - }; -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts deleted file mode 100644 index a5993303e4a..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/delete-record-step.builder.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { type ToolSet } from 'ai'; -import { z } from 'zod'; - -import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; -import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; - -import { - createAndConfigureStep, - type WorkflowStepToolsDeps, -} from './step-builder.utils'; - -export function buildDeleteRecordStepTool( - deps: WorkflowStepToolsDeps, - objectMetadata: ObjectMetadataForToolSchema, - context: ToolGeneratorContext, -): ToolSet { - const inputSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to add the step to'), - parentStepId: z - .string() - .optional() - .describe('Optional ID of the parent step this step should come after'), - stepName: z - .string() - .optional() - .describe( - `Name for this step (default: "Delete ${objectMetadata.labelSingular}")`, - ), - objectRecordId: z - .string() - .describe( - `The ID of the ${objectMetadata.labelSingular} record to delete. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`, - ), - }); - - return { - [`configure_delete_${objectMetadata.nameSingular}_step`]: { - description: - `Add a workflow step that deletes a ${objectMetadata.labelSingular} record. ` + - `This performs a soft delete (marks as deleted but preserves data).`, - inputSchema, - execute: async (parameters: z.infer) => { - try { - const { stepId, result } = await createAndConfigureStep( - deps, - context.workspaceId, - parameters.workflowVersionId, - WorkflowActionType.DELETE_RECORD, - parameters.parentStepId, - { - name: - parameters.stepName || `Delete ${objectMetadata.labelSingular}`, - type: WorkflowActionType.DELETE_RECORD, - valid: true, - settings: { - input: { - objectName: objectMetadata.nameSingular, - objectRecordId: parameters.objectRecordId, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: false }, - continueOnFailure: { value: false }, - }, - }, - }, - ); - - return { - success: true, - message: `Created workflow step to delete ${objectMetadata.labelSingular}`, - result: { stepId, step: result }, - }; - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create ${objectMetadata.labelSingular} delete workflow step: ${error.message}`, - }; - } - }, - }, - }; -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts deleted file mode 100644 index 799ce6d8201..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/find-records-step.builder.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { type ToolSet } from 'ai'; -import { - FieldMetadataType, - RelationType, - type RestrictedFieldsPermissions, -} from 'twenty-shared/types'; -import { z } from 'zod'; - -import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; -import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema'; -import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema'; -import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; -import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util'; -import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; - -import { - createAndConfigureStep, - type WorkflowStepToolsDeps, -} from './step-builder.utils'; - -export function buildFindRecordsStepTool( - deps: WorkflowStepToolsDeps, - objectMetadata: ObjectMetadataForToolSchema, - restrictedFields: RestrictedFieldsPermissions, - context: ToolGeneratorContext, -): ToolSet { - const filterShape: Record = {}; - - objectMetadata.fields.forEach((field) => { - if (shouldExcludeFieldFromAgentToolSchema(field)) { - return; - } - - if (restrictedFields?.[field.id]?.canRead === false) { - return; - } - - const filterSchema = generateFieldFilterZodSchema(field); - - if (!filterSchema) { - return; - } - - const isManyToOneRelationField = - isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && - field.settings?.relationType === RelationType.MANY_TO_ONE; - - filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] = - filterSchema; - }); - - const inputSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to add the step to'), - parentStepId: z - .string() - .optional() - .describe('Optional ID of the parent step this step should come after'), - stepName: z - .string() - .optional() - .describe( - `Name for this step (default: "Find ${objectMetadata.labelPlural}")`, - ), - limit: z - .number() - .int() - .positive() - .max(1000) - .optional() - .default(100) - .describe('Maximum number of records to return (default: 100)'), - orderBy: ObjectRecordOrderBySchema.optional().describe( - 'Sort records by field(s). Each item is an object with field name as key, sort direction as value.', - ), - filter: z - .object(filterShape) - .partial() - .optional() - .describe( - `Filter criteria for ${objectMetadata.labelPlural}. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`, - ), - }); - - return { - [`configure_find_${objectMetadata.namePlural}_step`]: { - description: - `Add a workflow step that searches for ${objectMetadata.labelPlural} records. ` + - `Results can be used in subsequent steps via {{stepId.result}}.`, - inputSchema, - execute: async (parameters: z.infer) => { - try { - const filterConfig = parameters.filter - ? { - recordFilters: Object.entries(parameters.filter).map( - ([fieldName, filterValue]) => ({ - fieldName, - filter: filterValue, - }), - ), - } - : undefined; - - const { stepId, result } = await createAndConfigureStep( - deps, - context.workspaceId, - parameters.workflowVersionId, - WorkflowActionType.FIND_RECORDS, - parameters.parentStepId, - { - name: parameters.stepName || `Find ${objectMetadata.labelPlural}`, - type: WorkflowActionType.FIND_RECORDS, - valid: true, - settings: { - input: { - objectName: objectMetadata.nameSingular, - limit: parameters.limit, - filter: filterConfig, - orderBy: parameters.orderBy - ? { gqlOperationOrderBy: parameters.orderBy } - : undefined, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: false }, - continueOnFailure: { value: false }, - }, - }, - }, - ); - - return { - success: true, - message: `Created workflow step to find ${objectMetadata.labelPlural}`, - result: { stepId, step: result }, - }; - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create ${objectMetadata.labelPlural} find workflow step: ${error.message}`, - }; - } - }, - }, - }; -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts deleted file mode 100644 index 588f5f0625d..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/step-builder.utils.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; - -export type WorkflowStepToolsDeps = { - workflowVersionStepService: { - createWorkflowVersionStep: (args: { - workspaceId: string; - input: { - workflowVersionId: string; - stepType: WorkflowActionType; - parentStepId?: string; - id?: string; - }; - }) => Promise; - updateWorkflowVersionStep: (args: { - workspaceId: string; - workflowVersionId: string; - step: unknown; - }) => Promise; - }; -}; - -export async function createAndConfigureStep( - deps: WorkflowStepToolsDeps, - workspaceId: string, - workflowVersionId: string, - stepType: WorkflowActionType, - parentStepId: string | undefined, - stepConfig: object, -) { - const stepId = uuidv4(); - - await deps.workflowVersionStepService.createWorkflowVersionStep({ - workspaceId, - input: { - workflowVersionId, - stepType, - parentStepId, - id: stepId, - }, - }); - - const result = - await deps.workflowVersionStepService.updateWorkflowVersionStep({ - workspaceId, - workflowVersionId, - step: { id: stepId, ...stepConfig }, - }); - - return { stepId, result }; -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts deleted file mode 100644 index a766e259549..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/builders/update-record-step.builder.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { type ToolSet } from 'ai'; -import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; -import { z } from 'zod'; - -import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; -import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema'; -import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; -import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum'; - -import { - createAndConfigureStep, - type WorkflowStepToolsDeps, -} from './step-builder.utils'; - -export function buildUpdateRecordStepTool( - deps: WorkflowStepToolsDeps, - objectMetadata: ObjectMetadataForToolSchema, - restrictedFields: RestrictedFieldsPermissions, - context: ToolGeneratorContext, -): ToolSet { - const recordPropertiesSchema = generateRecordPropertiesZodSchema( - objectMetadata, - false, - restrictedFields, - ); - const updateSchema = recordPropertiesSchema.partial(); - - const inputSchema = z.object({ - workflowVersionId: z - .string() - .describe('The ID of the workflow version to add the step to'), - parentStepId: z - .string() - .optional() - .describe('Optional ID of the parent step this step should come after'), - stepName: z - .string() - .optional() - .describe( - `Name for this step (default: "Update ${objectMetadata.labelSingular}")`, - ), - objectRecordId: z - .string() - .describe( - `The ID of the ${objectMetadata.labelSingular} record to update. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`, - ), - fieldsToUpdate: updateSchema.describe( - `The fields to update on the ${objectMetadata.labelSingular} record. Only include fields you want to change. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`, - ), - }); - - return { - [`configure_update_${objectMetadata.nameSingular}_step`]: { - description: - `Add a workflow step that updates an existing ${objectMetadata.labelSingular} record. ` + - `Specify the record ID and only the fields you want to update.`, - inputSchema, - execute: async (parameters: z.infer) => { - try { - const { stepId, result } = await createAndConfigureStep( - deps, - context.workspaceId, - parameters.workflowVersionId, - WorkflowActionType.UPDATE_RECORD, - parameters.parentStepId, - { - name: - parameters.stepName || `Update ${objectMetadata.labelSingular}`, - type: WorkflowActionType.UPDATE_RECORD, - valid: true, - settings: { - input: { - objectName: objectMetadata.nameSingular, - objectRecordId: parameters.objectRecordId, - objectRecord: parameters.fieldsToUpdate, - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: false }, - continueOnFailure: { value: false }, - }, - }, - }, - ); - - return { - success: true, - message: `Created workflow step to update ${objectMetadata.labelSingular}`, - result: { stepId, step: result }, - }; - } catch (error) { - return { - success: false, - error: error.message, - message: `Failed to create ${objectMetadata.labelSingular} update workflow step: ${error.message}`, - }; - } - }, - }, - }; -} diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts deleted file mode 100644 index c97714fa54b..00000000000 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type ToolSet } from 'ai'; - -import { - type ObjectWithPermission, - type ToolGeneratorContext, -} from 'src/engine/core-modules/tool-generator/types/tool-generator.types'; - -import { buildCreateRecordStepTool } from './builders/create-record-step.builder'; -import { buildDeleteRecordStepTool } from './builders/delete-record-step.builder'; -import { buildFindRecordsStepTool } from './builders/find-records-step.builder'; -import { type WorkflowStepToolsDeps } from './builders/step-builder.utils'; -import { buildUpdateRecordStepTool } from './builders/update-record-step.builder'; - -export { type WorkflowStepToolsDeps } from './builders/step-builder.utils'; - -export const createWorkflowStepToolsFactory = (deps: WorkflowStepToolsDeps) => { - return ( - { - objectMetadata, - restrictedFields, - canCreate, - canRead, - canUpdate, - canDelete, - }: ObjectWithPermission, - context: ToolGeneratorContext, - ): ToolSet => { - const tools: ToolSet = {}; - - if (canCreate) { - Object.assign( - tools, - buildCreateRecordStepTool( - deps, - objectMetadata, - restrictedFields, - context, - ), - ); - } - - if (canUpdate) { - Object.assign( - tools, - buildUpdateRecordStepTool( - deps, - objectMetadata, - restrictedFields, - context, - ), - ); - } - - if (canRead) { - Object.assign( - tools, - buildFindRecordsStepTool( - deps, - objectMetadata, - restrictedFields, - context, - ), - ); - } - - if (canDelete) { - Object.assign( - tools, - buildDeleteRecordStepTool(deps, objectMetadata, context), - ); - } - - return tools; - }; -}; diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service.ts index 168011ab261..0e0e307a171 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 @@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common'; 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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.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'; @@ -11,10 +10,6 @@ import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workfl import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; -import { - createWorkflowStepToolsFactory, - type WorkflowStepToolsDeps, -} from 'src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory'; import { createActivateWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool'; import { createComputeStepOutputSchemaTool } from 'src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool'; import { createCreateCompleteWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool'; @@ -34,7 +29,6 @@ import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-t @Injectable() export class WorkflowToolWorkspaceService { private readonly deps: WorkflowToolDependencies; - private readonly workflowStepToolsDeps: WorkflowStepToolsDeps; constructor( workflowVersionStepService: WorkflowVersionStepWorkspaceService, @@ -45,7 +39,6 @@ export class WorkflowToolWorkspaceService { workflowSchemaService: WorkflowSchemaWorkspaceService, globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, recordPositionService: RecordPositionService, - private readonly perObjectToolGenerator: PerObjectToolGeneratorService, ) { this.deps = { workflowVersionStepService, @@ -57,10 +50,6 @@ export class WorkflowToolWorkspaceService { globalWorkspaceOrmManager, recordPositionService, }; - - this.workflowStepToolsDeps = { - workflowVersionStepService, - }; } // Generates static workflow tools that don't depend on workspace objects @@ -136,22 +125,4 @@ export class WorkflowToolWorkspaceService { [getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion, }; } - - // Generates dynamic step configurator tools for each workspace object - async generateRecordStepConfiguratorTools( - workspaceId: string, - rolePermissionConfig: RolePermissionConfig, - ): Promise { - const workflowStepToolsFactory = createWorkflowStepToolsFactory( - this.workflowStepToolsDeps, - ); - - return this.perObjectToolGenerator.generate( - { - workspaceId, - rolePermissionConfig, - }, - [workflowStepToolsFactory], - ); - } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts index bc093bf9fc9..d4124847d42 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/workflow-tools.module.ts @@ -1,7 +1,6 @@ import { Global, Module } from '@nestjs/common'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; -import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module'; import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module'; @@ -22,7 +21,6 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace WorkflowTriggerModule, WorkflowSchemaModule, RecordPositionModule, - ToolGeneratorModule, ], providers: [ WorkflowToolWorkspaceService, diff --git a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts index 5204f03f3f0..abb7166cd8d 100644 --- a/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts +++ b/packages/twenty-shared/src/ai/types/ExtendedUIMessage.ts @@ -4,8 +4,10 @@ import { type UIMessage } from 'ai'; export type AIChatUsageMetadata = { inputTokens: number; outputTokens: number; + cachedInputTokens: number; inputCredits: number; outputCredits: number; + conversationSize: number; }; export type AIChatModelMetadata = { diff --git a/packages/twenty-shared/src/types/ExtractSerializedRelationProperties.type.ts b/packages/twenty-shared/src/types/ExtractSerializedRelationProperties.type.ts index 3c24ecfb67a..963c50eb5d5 100644 --- a/packages/twenty-shared/src/types/ExtractSerializedRelationProperties.type.ts +++ b/packages/twenty-shared/src/types/ExtractSerializedRelationProperties.type.ts @@ -1,4 +1,4 @@ -import { IsSerializedRelation } from "@/types/IsSerializedRelation.type"; +import { type IsSerializedRelation } from '@/types/IsSerializedRelation.type'; export type ExtractSerializedRelationProperties = T extends unknown ? T extends object diff --git a/packages/twenty-shared/src/types/IsSerializedRelation.type.ts b/packages/twenty-shared/src/types/IsSerializedRelation.type.ts index 804b6ec461e..baf95fdc232 100644 --- a/packages/twenty-shared/src/types/IsSerializedRelation.type.ts +++ b/packages/twenty-shared/src/types/IsSerializedRelation.type.ts @@ -1,4 +1,4 @@ -import { SERIALIZED_RELATION_BRAND } from '@/types/SerializedRelation.type'; +import { type SERIALIZED_RELATION_BRAND } from '@/types/SerializedRelation.type'; export type IsSerializedRelation = typeof SERIALIZED_RELATION_BRAND extends keyof T ? true : false; diff --git a/packages/twenty-shared/src/types/SettingsPath.ts b/packages/twenty-shared/src/types/SettingsPath.ts index a6974a7b723..7fb9c6bbe15 100644 --- a/packages/twenty-shared/src/types/SettingsPath.ts +++ b/packages/twenty-shared/src/types/SettingsPath.ts @@ -28,6 +28,7 @@ export enum SettingsPath { EmailingDomainDetail = 'domains/emailing-domain/:domainId', Updates = 'updates', AI = 'ai', + AIPrompts = 'ai/prompts', AINewAgent = 'ai/new-agent', AIAgentDetail = 'ai/agents/:agentId', AIAgentTurnDetail = 'ai/agents/:agentId/turns/:turnId', diff --git a/packages/twenty-shared/src/types/__tests__/extract-serialized-relation-properties.type-test.ts b/packages/twenty-shared/src/types/__tests__/extract-serialized-relation-properties.type-test.ts index 2ccc112af93..2554ed34e4d 100644 --- a/packages/twenty-shared/src/types/__tests__/extract-serialized-relation-properties.type-test.ts +++ b/packages/twenty-shared/src/types/__tests__/extract-serialized-relation-properties.type-test.ts @@ -81,5 +81,3 @@ type Assertions = [ > >, ]; - - diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index b46a43b6ea0..2a23fb91582 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -145,6 +145,7 @@ export { safeParseRelativeDateFilterJSONStringified } from './safeParseRelativeD export { getGenericOperationName } from './sentry/getGenericOperationName'; export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode'; export { appendCopySuffix } from './strings/appendCopySuffix'; +export { camelToSnakeCase } from './strings/camelToSnakeCase'; export { capitalize } from './strings/capitalize'; export { pascalCase } from './strings/pascalCase'; export { stringifySafely } from './strings/stringifySafely'; diff --git a/packages/twenty-shared/src/utils/strings/__tests__/camelToSnakeCase.test.ts b/packages/twenty-shared/src/utils/strings/__tests__/camelToSnakeCase.test.ts new file mode 100644 index 00000000000..8e6903b27d0 --- /dev/null +++ b/packages/twenty-shared/src/utils/strings/__tests__/camelToSnakeCase.test.ts @@ -0,0 +1,25 @@ +import { camelToSnakeCase } from '../camelToSnakeCase'; + +describe('camelToSnakeCase', () => { + it('should convert camelCase to snake_case', () => { + expect(camelToSnakeCase('cloudUserWorkspaces')).toBe( + 'cloud_user_workspaces', + ); + }); + + it('should convert single-word camelCase', () => { + expect(camelToSnakeCase('people')).toBe('people'); + }); + + it('should handle two-word camelCase', () => { + expect(camelToSnakeCase('selfHostingUser')).toBe('self_hosting_user'); + }); + + it('should handle already snake_case', () => { + expect(camelToSnakeCase('already_snake')).toBe('already_snake'); + }); + + it('should handle single word', () => { + expect(camelToSnakeCase('company')).toBe('company'); + }); +}); diff --git a/packages/twenty-shared/src/utils/strings/camelToSnakeCase.ts b/packages/twenty-shared/src/utils/strings/camelToSnakeCase.ts new file mode 100644 index 00000000000..864b721d074 --- /dev/null +++ b/packages/twenty-shared/src/utils/strings/camelToSnakeCase.ts @@ -0,0 +1,2 @@ +export const camelToSnakeCase = (str: string): string => + str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`); diff --git a/yarn.lock b/yarn.lock index cf6b2d91fa5..922395903b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -48,6 +48,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/groq@npm:^2.0.34": + version: 2.0.34 + resolution: "@ai-sdk/groq@npm:2.0.34" + dependencies: + "@ai-sdk/provider": "npm:2.0.1" + "@ai-sdk/provider-utils": "npm:3.0.20" + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + checksum: 10c0/1e1ad69db49e1b06f18f2e7181f8a094afc275bb9cd96edc614303ff0a86945ba0c25adcf3d2ccbf398eb275d9348fc6224ba8af60009a9a9e23bb25dc718a5f + languageName: node + linkType: hard + "@ai-sdk/openai-compatible@npm:1.0.30": version: 1.0.30 resolution: "@ai-sdk/openai-compatible@npm:1.0.30" @@ -56985,6 +56997,7 @@ __metadata: resolution: "twenty-server@workspace:packages/twenty-server" dependencies: "@ai-sdk/anthropic": "npm:^2.0.17" + "@ai-sdk/groq": "npm:^2.0.34" "@ai-sdk/openai": "npm:^2.0.30" "@ai-sdk/provider-utils": "npm:^3.0.9" "@ai-sdk/xai": "npm:^2.0.19"