feat: Add agent role assignment and database CRUD tools for AI agent nodes (#12888)

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 <moreaux.antoine@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
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 <paul@twenty.com>
Co-authored-by: Vicky Wang <157669812+vickywxng@users.noreply.github.com>
Co-authored-by: Vicky Wang <vw92@cornell.edu>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-30 01:48:14 +05:30
committed by GitHub
parent 317336ab71
commit 74b6466a57
53 changed files with 4804 additions and 478 deletions

View File

@ -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 ? (
<RightDrawerSkeletonLoader />
) : (
<>
<StyledTabList
tabs={tabs}
behaveAsLinks={false}
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
/>
<WorkflowStepHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
@ -77,41 +114,55 @@ export const WorkflowEditActionAiAgent = ({
disabled={actionOptions.readonly}
/>
<WorkflowStepBody>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
<>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{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.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{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.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</>
)}
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
<Select
dropdownId="select-agent-role"
label={t`Assign Role`}
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
value={selectedRole || ''}
onChange={handleRoleChange}
disabled={actionOptions.readonly}
/>
)}
</WorkflowStepBody>
</>
);

View File

@ -0,0 +1,7 @@
export enum WorkflowAiAgentTabId {
SETTINGS = 'settings',
TOOLS = 'tools',
}
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =
'WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID';

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const ASSIGN_ROLE_TO_AGENT = gql`
mutation AssignRoleToAgent($agentId: UUID!, $roleId: UUID!) {
assignRoleToAgent(agentId: $agentId, roleId: $roleId)
}
`;

View File

@ -0,0 +1,7 @@
import { gql } from '@apollo/client';
export const REMOVE_ROLE_FROM_AGENT = gql`
mutation RemoveRoleFromAgent($agentId: UUID!) {
removeRoleFromAgent(agentId: $agentId)
}
`;

View File

@ -9,6 +9,7 @@ export const FIND_ONE_AGENT = gql`
prompt
modelId
responseFormat
roleId
}
}
`;

View File

@ -0,0 +1,51 @@
import { workflowAiAgentSelectedRoleState } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/states/workflowAiAgentSelectedRoleState';
import { useRecoilState } from 'recoil';
import {
useAssignRoleToAgentMutation,
useFindOneAgentQuery,
useGetRolesQuery,
useRemoveRoleFromAgentMutation,
} from '~/generated/graphql';
export const useAgentRoleAssignment = (agentId: string) => {
const [workflowAiAgentSelectedRole, setWorkflowAiAgentSelectedRole] =
useRecoilState(workflowAiAgentSelectedRoleState);
useFindOneAgentQuery({
variables: { id: agentId },
skip: !agentId,
onCompleted: (data) => {
setWorkflowAiAgentSelectedRole(data.findOneAgent.roleId);
},
});
const { data: rolesData } = useGetRolesQuery();
const [assignRoleToAgent] = useAssignRoleToAgentMutation();
const [removeRoleFromAgent] = useRemoveRoleFromAgentMutation();
const handleRoleChange = async (roleId: string) => {
if (roleId === '') {
await handleRoleRemove();
} else {
setWorkflowAiAgentSelectedRole(roleId);
await assignRoleToAgent({ variables: { agentId, roleId } });
}
};
const handleRoleRemove = async () => {
setWorkflowAiAgentSelectedRole(undefined);
await removeRoleFromAgent({ variables: { agentId } });
};
const rolesOptions =
rolesData?.getRoles?.map((role) => ({
label: role.label,
value: role.id,
})) || [];
return {
selectedRole: workflowAiAgentSelectedRole,
handleRoleChange,
rolesOptions,
};
};

View File

@ -1,9 +1,9 @@
import { useMutation, useQuery } from '@apollo/client';
import { useMutation } from '@apollo/client';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useDebouncedCallback } from 'use-debounce';
import { useFindOneAgentQuery } from '~/generated/graphql';
import { UPDATE_ONE_AGENT } from '../graphql/mutations/updateOneAgent';
import { FIND_ONE_AGENT } from '../graphql/queries/findOneAgent';
type AgentFormValues = {
name: string;
@ -24,7 +24,7 @@ export const useAgentUpdateFormState = ({
modelId: '',
});
const { loading } = useQuery(FIND_ONE_AGENT, {
const { loading } = useFindOneAgentQuery({
variables: { id: agentId },
skip: !agentId,
onCompleted: (data) => {

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const workflowAiAgentSelectedRoleState = atom<string | undefined>({
key: 'workflowAiAgentSelectedRoleState',
default: undefined,
});