From 74b6466a571bca0a0d43786b5e05e98f932d285f Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Mon, 30 Jun 2025 01:48:14 +0530 Subject: [PATCH] feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a significant enhancement to the role-based permission system by extending it to support AI agents, enabling them to perform database operations based on assigned permissions. ## Key Changes ### 1. Database Schema Migration - **Table Rename**: `userWorkspaceRole` → `roleTargets` to better reflect its expanded purpose - **New Column**: Added `agentId` (UUID, nullable) to support AI agent role assignments - **Constraint Updates**: - Made `userWorkspaceId` nullable to accommodate agent-only role assignments - Added check constraint `CHK_role_targets_either_agent_or_user` ensuring either `agentId` OR `userWorkspaceId` is set (not both) ### 2. Entity & Service Layer Updates - **RoleTargetsEntity**: Updated with new `agentId` field and constraint validation - **AgentRoleService**: New service for managing agent role assignments with validation - **AgentService**: Enhanced to include role information when retrieving agents - **RoleResolver**: Added GraphQL mutations for `assignRoleToAgent` and `removeRoleFromAgent` ### 3. AI Agent CRUD Operations - **Permission-Based Tool Generation**: AI agents now receive database tools based on their assigned role permissions - **Dynamic Tool Creation**: The `AgentToolService` generates CRUD tools (`create_*`, `find_*`, `update_*`, `soft_delete_*`, `destroy_*`) for each object based on role permissions - **Granular Permissions**: Supports both global role permissions (`canReadAllObjectRecords`) and object-specific permissions (`canReadObjectRecords`) ### 4. Frontend Integration - **Role Assignment UI**: Added hooks and components for assigning/removing roles from agents ## Demo https://github.com/user-attachments/assets/41732267-742e-416c-b423-b687c2614c82 --------- Co-authored-by: Antoine Moreaux Co-authored-by: Lucas Bordeau Co-authored-by: Charles Bochet Co-authored-by: Guillim Co-authored-by: Charles Bochet Co-authored-by: Weiko Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: github-actions Co-authored-by: Félix Malfait Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com> Co-authored-by: martmull Co-authored-by: Thomas Trompette Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com> Co-authored-by: Baptiste Devessier Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com> Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Co-authored-by: prastoin Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com> Co-authored-by: Vicky Wang Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com> --- .../src/generated-metadata/graphql.ts | 14 + .../twenty-front/src/generated/graphql.tsx | 95 +- .../components/WorkflowEditActionAiAgent.tsx | 123 ++- .../constants/workflow-ai-agent-tabs.ts | 7 + .../graphql/mutations/assignRoleToAgent.ts | 7 + .../graphql/mutations/removeRoleFromAgent.ts | 7 + .../graphql/queries/findOneAgent.ts | 1 + .../hooks/useAgentRoleAssignment.ts | 51 + .../hooks/useAgentUpdateFormState.ts | 6 +- .../workflowAiAgentSelectedRoleState.ts | 6 + ...ame-user-workspace-role-to-role-targets.ts | 77 ++ .../graphql-query-runner.module.ts | 4 +- .../open-api/utils/components.utils.ts | 331 +----- .../agent-role/agent-role.module.ts | 22 + .../agent-role/agent-role.service.spec.ts | 364 +++++++ .../agent-role/agent-role.service.ts | 113 +++ .../agent/agent-execution.service.ts | 62 +- .../agent/agent-tool.service.ts | 856 ++++++++++++++++ .../metadata-modules/agent/agent.module.ts | 21 +- .../metadata-modules/agent/agent.service.ts | 16 +- .../agent/constants/agent-config.const.ts | 3 + .../constants/agent-system-prompts.const.ts | 71 ++ .../metadata-modules/agent/dtos/agent.dto.ts | 3 + .../agent/utils/agent-tool-schema.utils.ts | 836 +++++++++++++++ .../utils/is-workflow-related-object.util.ts | 18 + ...clude-field-from-agent-tool-schema.util.ts | 20 + .../permissions/permissions.module.ts | 6 +- .../metadata-modules/role/dtos/role.dto.ts | 4 +- ...-role.entity.ts => role-targets.entity.ts} | 27 +- .../metadata-modules/role/role.entity.ts | 8 +- .../metadata-modules/role/role.module.ts | 5 +- .../metadata-modules/role/role.resolver.ts | 35 + .../metadata-modules/role/role.service.ts | 11 +- .../utils/fromRoleEntityToRoleDto.util.ts | 36 +- .../user-role/user-role.module.ts | 4 +- .../user-role/user-role.service.ts | 25 +- .../workspace-permissions-cache.module.ts | 4 +- .../workspace-permissions-cache.service.ts | 12 +- .../twenty-orm/twenty-orm-global.manager.ts | 4 + .../engine/twenty-orm/twenty-orm.manager.ts | 12 +- .../engine/twenty-orm/twenty-orm.module.ts | 4 +- ...ject-metadata-to-schema-properties.util.ts | 343 +++++++ .../workspace-manager.service.spec.ts | 14 +- .../workspace-manager.module.ts | 4 +- .../workspace-manager.service.ts | 10 +- .../ai-agent/ai-agent.workflow-action.ts | 8 +- .../constants/agent-gql-fields.constants.ts | 21 + .../suites/agent/agent.integration-spec.ts | 251 +++++ .../create-agent-operation-factory.util.ts | 33 + .../delete-agent-operation-factory.util.ts | 15 + .../update-agent-operation-factory.util.ts | 36 + .../agent-tool.service.integration-spec.ts | 954 ++++++++++++++++++ .../agent/utils/agent-tool-test-utils.ts | 262 +++++ 53 files changed, 4804 insertions(+), 478 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/workflow-ai-agent-tabs.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/assignRoleToAgent.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/graphql/mutations/removeRoleFromAgent.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentRoleAssignment.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentSelectedRoleState.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1749000000000-rename-user-workspace-role-to-role-targets.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent-role/agent-role.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-config.const.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/constants/agent-system-prompts.const.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/utils/agent-tool-schema.utils.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/utils/is-workflow-related-object.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts rename packages/twenty-server/src/engine/metadata-modules/role/{user-workspace-role.entity.ts => role-targets.entity.ts} (53%) create mode 100644 packages/twenty-server/src/engine/utils/convert-object-metadata-to-schema-properties.util.ts create mode 100644 packages/twenty-server/test/integration/constants/agent-gql-fields.constants.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/agent/agent.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/create-agent-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/delete-agent-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/graphql/utils/update-agent-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 57be9e923..8e57eca80 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -70,6 +70,7 @@ export type Agent = { name: Scalars['String']['output']; prompt: Scalars['String']['output']; responseFormat?: Maybe; + roleId?: Maybe; updatedAt: Scalars['DateTime']['output']; }; @@ -1003,6 +1004,7 @@ export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']['output']; activateWorkspace: Workspace; + assignRoleToAgent: Scalars['Boolean']['output']; authorizeApp: AuthorizeApp; checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; @@ -1049,6 +1051,7 @@ export type Mutation = { getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; impersonate: ImpersonateOutput; publishServerlessFunction: ServerlessFunction; + removeRoleFromAgent: Scalars['Boolean']['output']; renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; @@ -1103,6 +1106,12 @@ export type MutationActivateWorkspaceArgs = { }; +export type MutationAssignRoleToAgentArgs = { + agentId: Scalars['UUID']['input']; + roleId: Scalars['UUID']['input']; +}; + + export type MutationAuthorizeAppArgs = { clientId: Scalars['String']['input']; codeChallenge?: InputMaybe; @@ -1317,6 +1326,11 @@ export type MutationPublishServerlessFunctionArgs = { }; +export type MutationRemoveRoleFromAgentArgs = { + agentId: Scalars['UUID']['input']; +}; + + export type MutationRenewTokenArgs = { appToken: Scalars['String']['input']; }; diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 53e98e9f6..15f5cc988 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -62,6 +62,7 @@ export type Agent = { name: Scalars['String']; prompt: Scalars['String']; responseFormat?: Maybe; + roleId?: Maybe; updatedAt: Scalars['DateTime']; }; @@ -952,6 +953,7 @@ export type Mutation = { __typename?: 'Mutation'; activateWorkflowVersion: Scalars['Boolean']; activateWorkspace: Workspace; + assignRoleToAgent: Scalars['Boolean']; authorizeApp: AuthorizeApp; checkCustomDomainValidRecords?: Maybe; checkoutSession: BillingSessionOutput; @@ -996,6 +998,7 @@ export type Mutation = { getLoginTokenFromEmailVerificationToken: GetLoginTokenFromEmailVerificationTokenOutput; impersonate: ImpersonateOutput; publishServerlessFunction: ServerlessFunction; + removeRoleFromAgent: Scalars['Boolean']; renewToken: AuthTokens; resendEmailVerificationToken: ResendEmailVerificationTokenOutput; resendWorkspaceInvitation: SendInvitationsOutput; @@ -1046,6 +1049,12 @@ export type MutationActivateWorkspaceArgs = { }; +export type MutationAssignRoleToAgentArgs = { + agentId: Scalars['UUID']; + roleId: Scalars['UUID']; +}; + + export type MutationAuthorizeAppArgs = { clientId: Scalars['String']; codeChallenge?: InputMaybe; @@ -1240,6 +1249,11 @@ export type MutationPublishServerlessFunctionArgs = { }; +export type MutationRemoveRoleFromAgentArgs = { + agentId: Scalars['UUID']; +}; + + export type MutationRenewTokenArgs = { appToken: Scalars['String']; }; @@ -3180,6 +3194,21 @@ export type UpdateWorkflowVersionStepMutationVariables = Exact<{ export type UpdateWorkflowVersionStepMutation = { __typename?: 'Mutation', updateWorkflowVersionStep: { __typename?: 'WorkflowAction', id: any, name: string, type: string, settings: any, valid: boolean, nextStepIds?: Array | null } }; +export type AssignRoleToAgentMutationVariables = Exact<{ + agentId: Scalars['UUID']; + roleId: Scalars['UUID']; +}>; + + +export type AssignRoleToAgentMutation = { __typename?: 'Mutation', assignRoleToAgent: boolean }; + +export type RemoveRoleFromAgentMutationVariables = Exact<{ + agentId: Scalars['UUID']; +}>; + + +export type RemoveRoleFromAgentMutation = { __typename?: 'Mutation', removeRoleFromAgent: boolean }; + export type UpdateOneAgentMutationVariables = Exact<{ input: UpdateAgentInput; }>; @@ -3192,7 +3221,7 @@ export type FindOneAgentQueryVariables = Exact<{ }>; -export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null } }; +export type FindOneAgentQuery = { __typename?: 'Query', findOneAgent: { __typename?: 'Agent', id: any, name: string, description?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: any | null } }; export type SubmitFormStepMutationVariables = Exact<{ input: SubmitFormStepInput; @@ -6569,6 +6598,69 @@ export function useUpdateWorkflowVersionStepMutation(baseOptions?: Apollo.Mutati export type UpdateWorkflowVersionStepMutationHookResult = ReturnType; export type UpdateWorkflowVersionStepMutationResult = Apollo.MutationResult; export type UpdateWorkflowVersionStepMutationOptions = Apollo.BaseMutationOptions; +export const AssignRoleToAgentDocument = gql` + mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) { + assignRoleToAgent(agentId: $agentId, roleId: $roleId) +} + `; +export type AssignRoleToAgentMutationFn = Apollo.MutationFunction; + +/** + * __useAssignRoleToAgentMutation__ + * + * To run a mutation, you first call `useAssignRoleToAgentMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAssignRoleToAgentMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [assignRoleToAgentMutation, { data, loading, error }] = useAssignRoleToAgentMutation({ + * variables: { + * agentId: // value for 'agentId' + * roleId: // value for 'roleId' + * }, + * }); + */ +export function useAssignRoleToAgentMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AssignRoleToAgentDocument, options); + } +export type AssignRoleToAgentMutationHookResult = ReturnType; +export type AssignRoleToAgentMutationResult = Apollo.MutationResult; +export type AssignRoleToAgentMutationOptions = Apollo.BaseMutationOptions; +export const RemoveRoleFromAgentDocument = gql` + mutation RemoveRoleFromAgent($agentId: UUID!) { + removeRoleFromAgent(agentId: $agentId) +} + `; +export type RemoveRoleFromAgentMutationFn = Apollo.MutationFunction; + +/** + * __useRemoveRoleFromAgentMutation__ + * + * To run a mutation, you first call `useRemoveRoleFromAgentMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRemoveRoleFromAgentMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [removeRoleFromAgentMutation, { data, loading, error }] = useRemoveRoleFromAgentMutation({ + * variables: { + * agentId: // value for 'agentId' + * }, + * }); + */ +export function useRemoveRoleFromAgentMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RemoveRoleFromAgentDocument, options); + } +export type RemoveRoleFromAgentMutationHookResult = ReturnType; +export type RemoveRoleFromAgentMutationResult = Apollo.MutationResult; +export type RemoveRoleFromAgentMutationOptions = Apollo.BaseMutationOptions; export const UpdateOneAgentDocument = gql` mutation UpdateOneAgent($input: UpdateAgentInput!) { updateOneAgent(input: $input) { @@ -6616,6 +6708,7 @@ export const FindOneAgentDocument = gql` prompt modelId responseFormat + roleId } } `; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx index 42b15756a..3d3e4bf8f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/ai-agent-action/components/WorkflowEditActionAiAgent.tsx @@ -1,5 +1,8 @@ import { FormTextFieldInput } from '@/object-record/record-field/form-types/components/FormTextFieldInput'; import { Select } from '@/ui/input/components/Select'; +import { TabList } from '@/ui/layout/tab-list/components/TabList'; +import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { WorkflowAiAgentAction } from '@/workflow/types/Workflow'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; @@ -8,8 +11,13 @@ import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema'; import styled from '@emotion/styled'; import { t } from '@lingui/core/macro'; -import { useIcons } from 'twenty-ui/display'; +import { IconSettings, IconTool, useIcons } from 'twenty-ui/display'; import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader'; +import { + WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID, + WorkflowAiAgentTabId, +} from '../constants/workflow-ai-agent-tabs'; +import { useAgentRoleAssignment } from '../hooks/useAgentRoleAssignment'; import { useAgentUpdateFormState } from '../hooks/useAgentUpdateFormState'; import { useAiAgentOutputSchema } from '../hooks/useAiAgentOutputSchema'; import { useAiModelOptions } from '../hooks/useAiModelOptions'; @@ -22,6 +30,11 @@ const StyledErrorMessage = styled.div` margin-top: ${({ theme }) => theme.spacing(1)}; `; +const StyledTabList = styled(TabList)` + background-color: ${({ theme }) => theme.background.secondary}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + type WorkflowEditActionAiAgentProps = { action: WorkflowAiAgentAction; actionOptions: @@ -43,8 +56,10 @@ export const WorkflowEditActionAiAgent = ({ defaultTitle: 'AI Agent', }); + const agentId = action.settings.input.agentId; + const { formValues, handleFieldChange, loading } = useAgentUpdateFormState({ - agentId: action.settings.input.agentId, + agentId, readonly: actionOptions.readonly === true, }); @@ -59,10 +74,32 @@ export const WorkflowEditActionAiAgent = ({ const noModelsAvailable = modelOptions.length === 0; + const activeTabId = useRecoilComponentValueV2( + activeTabIdComponentState, + WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID, + ); + + const { rolesOptions, selectedRole, handleRoleChange } = + useAgentRoleAssignment(agentId); + + const tabs = [ + { + id: WorkflowAiAgentTabId.SETTINGS, + title: t`Settings`, + Icon: IconSettings, + }, + { id: WorkflowAiAgentTabId.TOOLS, title: t`Tools`, Icon: IconTool }, + ]; + return loading ? ( ) : ( <> + { if (actionOptions.readonly === true) { @@ -77,41 +114,55 @@ export const WorkflowEditActionAiAgent = ({ disabled={actionOptions.readonly} /> -
- handleFieldChange('modelId', value)} + disabled={actionOptions.readonly || noModelsAvailable} + emptyOption={{ + label: t`No AI models available`, + value: '', + }} + /> - {noModelsAvailable && ( - - {t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`} - - )} -
- handleFieldChange('prompt', value)} - VariablePicker={WorkflowVariablePicker} - multiline - /> - + {noModelsAvailable && ( + + {t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`} + + )} + + handleFieldChange('prompt', value)} + VariablePicker={WorkflowVariablePicker} + multiline + /> + + + )} + {activeTabId === WorkflowAiAgentTabId.TOOLS && ( +