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:
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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';
|
||||
@ -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)
|
||||
}
|
||||
`;
|
||||
@ -0,0 +1,7 @@
|
||||
import { gql } from '@apollo/client';
|
||||
|
||||
export const REMOVE_ROLE_FROM_AGENT = gql`
|
||||
mutation RemoveRoleFromAgent($agentId: UUID!) {
|
||||
removeRoleFromAgent(agentId: $agentId)
|
||||
}
|
||||
`;
|
||||
@ -9,6 +9,7 @@ export const FIND_ONE_AGENT = gql`
|
||||
prompt
|
||||
modelId
|
||||
responseFormat
|
||||
roleId
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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) => {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const workflowAiAgentSelectedRoleState = atom<string | undefined>({
|
||||
key: 'workflowAiAgentSelectedRoleState',
|
||||
default: undefined,
|
||||
});
|
||||
Reference in New Issue
Block a user