feat(ai): add browsing context and fix tool loading (#16476)

## Summary

- Add `BrowsingContext` type to automatically pass what the user is
currently viewing (recordPage or listView) to the AI chat
- Simplify context architecture: remove toggleable context UI, make it
automatic and invisible to the user
- Fix tool loading: add `unionOf` handling in
`getDatabaseToolsForObject` and fix regex ordering so `find_one_*` tools
are properly registered
- Use plural names for find tools (`find_people` vs `find_one_person`)
for better semantics
- Clean up unused components and states

## Changes

### Frontend
- New `BrowsingContext` type and `useGetBrowsingContext` hook to gather
context from Recoil state
- Simplified `useAgentChat` to use the new browsing context
- Removed toggleable context UI components
(`AgentChatContextRecordPreview`, `SendMessageWithRecordsContextButton`,
etc.)
- Removed `isAgentChatCurrentContextActiveState`

### Backend
- New `BrowsingContextType` for recordPage and listView contexts
- Updated `ChatExecutionService` to build context from browsing context
- Fixed `tool-registry.service.ts`:
  - Added `unionOf` handling in permission config
- Fixed regex ordering (`find_one` before `find`) so tools load
correctly
- Use plural names for search tools (`find_people` instead of
`find_person`)

## Test plan

- [x] Typecheck passes
- [x] Lint passes
- [ ] Test AI chat on record page - should show context in system prompt
- [ ] Test AI chat on list view - should show view name and filters
- [ ] Test `find_one_*` tools now load correctly
- [ ] Test `find_*` tools use plural naming
This commit is contained in:
Félix Malfait
2025-12-11 10:19:27 +01:00
committed by GitHub
parent 5daef28328
commit bc57b8ee4e
35 changed files with 696 additions and 834 deletions
@@ -1,5 +1,4 @@
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
@@ -43,8 +42,6 @@ const StyledErrorMessage = styled.div`
type AIChatErrorMessageProps = {
error: Error;
records?: ObjectRecord[];
isRetrying?: boolean;
};
export const AIChatErrorMessage = ({ error }: AIChatErrorMessageProps) => {
@@ -8,10 +8,7 @@ import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
import { AIChatAssistantMessageRenderer } from '@/ai/components/AIChatAssistantMessageRenderer';
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
import { AIChatErrorMessageWithRecordsContext } from '@/ai/components/internal/AIChatErrorMessageWithRecordsContext';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { LightCopyIconButton } from '@/object-record/record-field/ui/components/LightCopyIconButton';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
@@ -156,10 +153,6 @@ export const AIChatMessage = ({
const theme = useTheme();
const { localeCatalog } = useRecoilValue(dateLocaleState);
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const showError =
isDefined(error) && message.role === AgentMessageRole.ASSISTANT;
@@ -201,12 +194,7 @@ export const AIChatMessage = ({
))}
</StyledFilesContainer>
)}
{showError &&
(contextStoreCurrentObjectMetadataItemId ? (
<AIChatErrorMessageWithRecordsContext error={error} />
) : (
<AIChatErrorMessage error={error} />
))}
{showError && <AIChatErrorMessage error={error} />}
{message.parts.length > 0 && message.metadata?.createdAt && (
<StyledMessageFooter className="message-footer">
<span>
@@ -14,14 +14,11 @@ import { AIChatMessage } from '@/ai/components/AIChatMessage';
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { SendMessageWithRecordsContextButton } from '@/ai/components/internal/SendMessageWithRecordsContextButton';
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState } from 'recoil';
@@ -71,10 +68,6 @@ export const AIChatTab = () => {
const [agentChatInput, setAgentChatInput] =
useRecoilState(agentChatInputState);
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const { uploadFiles } = useAIChatFileUpload();
const { createChatThread } = useCreateNewAIChatThread();
const { navigateCommandMenu } = useCommandMenu();
@@ -145,11 +138,7 @@ export const AIChatTab = () => {
onClick={() => createChatThread()}
/>
<AgentChatFileUploadButton />
{contextStoreCurrentObjectMetadataItemId ? (
<SendMessageWithRecordsContextButton />
) : (
<SendMessageButton />
)}
<SendMessageButton />
</StyledButtonsContainer>
</StyledInputArea>
</>
@@ -1,14 +0,0 @@
import { AIChatErrorMessage } from '@/ai/components/AIChatErrorMessage';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
export const AIChatErrorMessageWithRecordsContext = ({
error,
}: {
error: Error;
}) => {
const { records } = useFindManyRecordsSelectedInContextStore({
limit: 10,
});
return <AIChatErrorMessage error={error} records={records} />;
};
@@ -1,8 +1,5 @@
import { AgentChatContextRecordPreview } from '@/ai/components/internal/AgentChatContextRecordPreview';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { AgentChatFilePreview } from './AgentChatFilePreview';
@@ -29,15 +26,18 @@ export const AgentChatContextPreview = () => {
agentChatUploadedFilesState,
);
const handleRemoveUploadedFile = async (fileIndex: number) => {
const handleRemoveUploadedFile = (fileIndex: number) => {
setAgentChatUploadedFiles(
agentChatUploadedFiles.filter((f, index) => fileIndex !== index),
agentChatUploadedFiles.filter((_, index) => fileIndex !== index),
);
};
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const hasFiles =
agentChatSelectedFiles.length > 0 || agentChatUploadedFiles.length > 0;
if (!hasFiles) {
return null;
}
return (
<StyledContainer>
@@ -62,13 +62,6 @@ export const AgentChatContextPreview = () => {
isUploading={false}
/>
))}
{contextStoreCurrentObjectMetadataItemId && (
<AgentChatContextRecordPreview
contextStoreCurrentObjectMetadataItemId={
contextStoreCurrentObjectMetadataItemId
}
/>
)}
</StyledPreviewsContainer>
</StyledContainer>
);
@@ -1,102 +0,0 @@
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useRecoilState } from 'recoil';
import { MultipleAvatarChip } from 'twenty-ui/components';
import { IconReload, IconX } from 'twenty-ui/display';
const StyledRightIconContainer = styled.div`
display: flex;
border-left: 1px solid ${({ theme }) => theme.border.color.light};
svg {
cursor: pointer;
}
`;
const StyledChipWrapper = styled.div<{ isActive: boolean }>`
opacity: ${({ isActive }) => (isActive ? 1 : 0.7)};
`;
export const AgentChatContextRecordPreview = ({
contextStoreCurrentObjectMetadataItemId,
}: {
contextStoreCurrentObjectMetadataItemId: string;
}) => {
const theme = useTheme();
const { records, totalCount } = useFindManyRecordsSelectedInContextStore({
limit: 3,
});
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataItemId,
});
const [isAgentChatCurrentContextActive, setIsAgentChatCurrentContextActive] =
useRecoilState(isAgentChatCurrentContextActiveState);
const Avatars = records.map((record) => (
// @todo move this components to be less specific. (Outside of CommandMenu
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
));
const recordSelectionContextChip = {
// @todo move this utils outside of CommandMenu
text: getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount ?? 0,
),
Icons: Avatars,
withIconBackground: false,
};
const toggleIsAgentChatCurrentContextActive = () => {
setIsAgentChatCurrentContextActive(!isAgentChatCurrentContextActive);
};
return (
<>
{records.length !== 0 && (
<StyledChipWrapper isActive={isAgentChatCurrentContextActive}>
<MultipleAvatarChip
Icons={recordSelectionContextChip.Icons}
text={
isAgentChatCurrentContextActive
? recordSelectionContextChip.text
: t`Context`
}
maxWidth={180}
rightComponent={
<StyledRightIconContainer>
{isAgentChatCurrentContextActive ? (
<IconX
size={theme.icon.size.sm}
color={theme.font.color.secondary}
onClick={toggleIsAgentChatCurrentContextActive}
/>
) : (
<IconReload
size={theme.icon.size.sm}
color={theme.font.color.secondary}
onClick={toggleIsAgentChatCurrentContextActive}
/>
)}
</StyledRightIconContainer>
}
/>
</StyledChipWrapper>
)}
</>
);
};
@@ -1,18 +1,13 @@
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
import { agentChatInputState } from '@/ai/states/agentChatInputState';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { Button } from 'twenty-ui/input';
export const SendMessageButton = ({
records,
}: {
records?: ObjectRecord[];
}) => {
export const SendMessageButton = () => {
const agentChatInput = useRecoilValue(agentChatInputState);
const { handleSendMessage, isLoading } = useAgentChatContextOrThrow();
@@ -21,7 +16,7 @@ export const SendMessageButton = ({
callback: (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
handleSendMessage(records);
handleSendMessage();
}
},
focusId: AI_CHAT_INPUT_ID,
@@ -34,7 +29,7 @@ export const SendMessageButton = ({
return (
<Button
hotkeys={agentChatInput && !isLoading ? ['⏎'] : undefined}
onClick={() => handleSendMessage(records)}
onClick={() => handleSendMessage()}
disabled={!agentChatInput || isLoading}
variant="primary"
accent="blue"
@@ -1,10 +0,0 @@
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
export const SendMessageWithRecordsContextButton = () => {
const { records } = useFindManyRecordsSelectedInContextStore({
limit: 10,
});
return <SendMessageButton records={records} />;
};
@@ -1,6 +1,5 @@
import { createContext } from 'react';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { type ObjectRecord } from '../../object-record/types/ObjectRecord';
export type AgentChatContextValue = {
messages: ExtendedUIMessage[];
@@ -8,7 +7,7 @@ export type AgentChatContextValue = {
isLoading: boolean;
error?: Error;
handleSendMessage: (records?: ObjectRecord[]) => Promise<void>;
handleSendMessage: () => Promise<void>;
handleRetry: () => void;
};
@@ -1,17 +1,13 @@
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { useGetBrowsingContext } from '@/ai/hooks/useBrowsingContext';
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
import { agentChatUploadedFilesState } from '@/ai/states/agentChatUploadedFilesState';
import { currentAIChatThreadState } from '@/ai/states/currentAIChatThreadState';
import { isAgentChatCurrentContextActiveState } from '@/ai/states/isAgentChatCurrentContextActiveState';
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
import { tokenPairState } from '@/auth/states/tokenPairState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useGetObjectMetadataItemById } from '@/object-metadata/hooks/useGetObjectMetadataItemById';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
@@ -24,15 +20,7 @@ import { agentChatInputState } from '../states/agentChatInputState';
export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
const setTokenPair = useSetRecoilState(tokenPairState);
const { getObjectMetadataItemById } = useGetObjectMetadataItemById();
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const isAgentChatCurrentContextActive = useRecoilValue(
isAgentChatCurrentContextActiveState,
);
const { getBrowsingContext } = useGetBrowsingContext();
const agentChatSelectedFiles = useRecoilValue(agentChatSelectedFilesState);
@@ -118,7 +106,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
const isLoading =
!currentAIChatThread || isStreaming || agentChatSelectedFiles.length > 0;
const handleSendMessage = async (records?: ObjectRecord[]) => {
const handleSendMessage = async () => {
if (agentChatInput.trim() === '' || isLoading === true) {
return;
}
@@ -126,20 +114,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
const content = agentChatInput.trim();
setAgentChatInput('');
const recordIdsByObjectMetadataNameSingular = [];
if (
isAgentChatCurrentContextActive === true &&
isDefined(records) &&
isDefined(contextStoreCurrentObjectMetadataItemId)
) {
recordIdsByObjectMetadataNameSingular.push({
objectMetadataNameSingular: getObjectMetadataItemById(
contextStoreCurrentObjectMetadataItemId,
).nameSingular,
recordIds: records.map(({ id }) => id),
});
}
const browsingContext = getBrowsingContext();
sendMessage(
{
@@ -149,7 +124,7 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
{
body: {
threadId: currentAIChatThread,
recordIdsByObjectMetadataNameSingular,
browsingContext,
},
},
);
@@ -0,0 +1,126 @@
import { type BrowsingContext } from '@/ai/types/BrowsingContext';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { coreViewFromViewIdFamilySelector } from '@/views/states/selectors/coreViewFromViewIdFamilySelector';
import { useRecoilCallback } from 'recoil';
export const useGetBrowsingContext = () => {
const getBrowsingContext = useRecoilCallback(
({ snapshot }) =>
(): BrowsingContext | null => {
const instanceId = MAIN_CONTEXT_STORE_INSTANCE_ID;
const viewType = snapshot
.getLoadable(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId,
}),
)
.getValue();
const objectMetadataItemId = snapshot
.getLoadable(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId,
}),
)
.getValue();
const objectMetadataItems = snapshot
.getLoadable(objectMetadataItemsState)
.getValue();
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === objectMetadataItemId,
);
if (!objectMetadataItem) {
return null;
}
if (viewType === ContextStoreViewType.ShowPage) {
const targetedRecordsRule = snapshot
.getLoadable(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId,
}),
)
.getValue();
if (
targetedRecordsRule.mode !== 'selection' ||
targetedRecordsRule.selectedRecordIds.length !== 1
) {
return null;
}
return {
type: 'recordPage',
objectNameSingular: objectMetadataItem.nameSingular,
recordId: targetedRecordsRule.selectedRecordIds[0],
};
}
if (
viewType === ContextStoreViewType.Table ||
viewType === ContextStoreViewType.Kanban
) {
const currentViewId = snapshot
.getLoadable(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId,
}),
)
.getValue();
const currentView = snapshot
.getLoadable(
coreViewFromViewIdFamilySelector({
viewId: currentViewId ?? '',
}),
)
.getValue();
if (!currentView) {
return null;
}
const contextStoreFilters = snapshot
.getLoadable(
contextStoreFiltersComponentState.atomFamily({
instanceId,
}),
)
.getValue();
const filterDescriptions = contextStoreFilters.map((filter) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === filter.fieldMetadataId,
);
const fieldLabel = fieldMetadataItem?.label ?? 'Unknown field';
return `${fieldLabel} ${filter.operand} "${filter.displayValue}"`;
});
return {
type: 'listView',
objectNameSingular: objectMetadataItem.nameSingular,
viewId: currentView.id,
viewName: currentView.name,
filterDescriptions,
};
}
return null;
},
[],
);
return { getBrowsingContext };
};
@@ -1,6 +0,0 @@
import { atom } from 'recoil';
export const isAgentChatCurrentContextActiveState = atom<boolean>({
key: 'ai/isAgentChatCurrentContextActiveState',
default: true,
});
@@ -0,0 +1,13 @@
export type BrowsingContext =
| {
type: 'recordPage';
objectNameSingular: string;
recordId: string;
}
| {
type: 'listView';
objectNameSingular: string;
viewId: string;
viewName: string;
filterDescriptions: string[];
};
@@ -37,8 +37,8 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
const tools: ToolSet = {};
if (canRead) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} 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.`,
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,
@@ -0,0 +1 @@
export const TOOL_PROVIDERS = Symbol('TOOL_PROVIDERS');
@@ -0,0 +1,20 @@
import { type ToolSet } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
actorContext?: ActorMetadata;
};
export interface ToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type ToolProvider,
type ToolProviderContext,
} 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 { 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 } 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;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Action tools are always available (individual tool permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.HTTP_REQUEST_TOOL,
);
if (hasHttpPermission) {
tools['http_request'] = this.createToolEntry(
this.httpTool,
context.workspaceId,
);
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.SEND_EMAIL_TOOL,
);
if (hasEmailPermission) {
tools['send_email'] = this.createToolEntry(
this.sendEmailTool,
context.workspaceId,
);
}
tools['search_help_center'] = this.createToolEntry(
this.searchHelpCenterTool,
context.workspaceId,
);
return tools;
}
private createToolEntry(tool: Tool, workspaceId: string) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, workspaceId),
};
}
}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type ToolProviderContext,
} 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 { 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 { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
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';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class DatabaseToolProvider implements ToolProvider {
readonly category = ToolCategory.DATABASE_CRUD;
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,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Database tools are always available (per-object permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'rolesPermissions',
]);
const objectPermissions = this.getObjectPermissions(
rolesPermissions,
context.rolePermissionConfig,
);
if (!objectPermissions) {
return tools;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
continue;
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
continue;
}
const objectMetadata = {
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
};
const objectTools = factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
actorContext: context.actorContext,
},
);
Object.assign(tools, objectTools);
}
return tools;
}
private getObjectPermissions(
rolesPermissions: ObjectsPermissionsByRoleId,
rolePermissionConfig: ToolProviderContext['rolePermissionConfig'],
): ObjectsPermissions | null {
if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
return allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
}
if ('unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
return rolesPermissions[rolePermissionConfig.unionOf[0]];
}
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
return null;
}
}
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type ToolProvider,
type ToolProviderContext,
} 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 { 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 {
readonly category = ToolCategory.METADATA;
constructor(
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.DATA_MODEL,
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
return {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
}
}
@@ -0,0 +1,49 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { 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 {
readonly category = ToolCategory.WORKFLOW;
constructor(
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.workflowToolService) {
return false;
}
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.WORKFLOWS,
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
if (!this.workflowToolService) {
return {};
}
return this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
}
}
@@ -14,7 +14,7 @@ import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-c
import { type ToolSpecification } from 'src/engine/core-modules/tool-provider/types/tool-specification.type';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { 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 } from 'src/engine/core-modules/tool/types/tool.type';
@@ -41,7 +41,7 @@ export class ToolProviderService {
// Action tools (individual tools)
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchArticlesTool: SearchArticlesTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
// Database CRUD tools
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
private readonly createRecordService: CreateRecordService,
@@ -80,9 +80,9 @@ export class ToolProviderService {
},
],
[
ToolType.SEARCH_ARTICLES,
ToolType.SEARCH_HELP_CENTER,
{
tool: this.searchArticlesTool,
tool: this.searchHelpCenterTool,
// No permission flag - available to all agents
},
],
@@ -1,28 +1,16 @@
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ActorMetadata } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import {
type ToolProvider,
type ToolProviderContext,
} 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 { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
export type ToolIndexEntry = {
name: string;
@@ -43,109 +31,35 @@ export type ToolContext = {
actorContext?: ActorMetadata;
};
// Workflow tool definitions for the index (static metadata)
const WORKFLOW_TOOLS_METADATA: Array<{ name: string; description: string }> = [
{
name: 'create_complete_workflow',
description:
'Create a complete workflow with trigger, steps, and connections in a single operation',
},
{
name: 'create_workflow_version_step',
description: 'Create a new step in a workflow version',
},
{
name: 'update_workflow_version_step',
description: 'Update an existing step in a workflow version',
},
{
name: 'delete_workflow_version_step',
description: 'Delete a step from a workflow version',
},
{
name: 'create_workflow_version_edge',
description: 'Create a connection (edge) between two workflow steps',
},
{
name: 'delete_workflow_version_edge',
description: 'Delete a connection (edge) between workflow steps',
},
{
name: 'create_draft_from_workflow_version',
description: 'Create a new draft workflow version from an existing one',
},
{
name: 'update_workflow_version_positions',
description: 'Update the positions of multiple workflow steps',
},
{
name: 'activate_workflow_version',
description:
'Activate a workflow version to make it available for execution',
},
{
name: 'deactivate_workflow_version',
description: 'Deactivate a workflow version',
},
{
name: 'compute_step_output_schema',
description: 'Compute the output schema for a workflow step',
},
];
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly permissionsService: PermissionsService,
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchArticlesTool: SearchArticlesTool,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
) {}
async buildToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const context = this.buildContext(workspaceId, roleId);
const entries: ToolIndexEntry[] = [];
const actionTools = await this.getActionToolIndex(workspaceId, roleId);
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const tools = await provider.generateTools(context);
index.push(...actionTools);
const databaseTools = await this.getDatabaseToolIndex(workspaceId, roleId);
index.push(...databaseTools);
if (this.workflowToolService) {
const workflowTools = this.getWorkflowToolIndex();
index.push(...workflowTools);
entries.push(...this.toolSetToIndex(tools, provider.category));
}
}
this.logger.log(
`Built tool index with ${index.length} tools for workspace ${workspaceId}`,
`Built tool index with ${entries.length} tools for workspace ${workspaceId}`,
);
return index;
}
private getWorkflowToolIndex(): ToolIndexEntry[] {
return WORKFLOW_TOOLS_METADATA.map((tool) => ({
name: tool.name,
description: tool.description,
category: 'workflow' as const,
}));
return entries;
}
async searchTools(
@@ -170,17 +84,14 @@ export class ToolRegistryService {
const descLower = tool.description.toLowerCase();
const objectLower = tool.objectName?.toLowerCase() ?? '';
// Exact name match - highest priority
if (nameLower.includes(queryLower)) {
score += 100;
}
// Object name match - high priority
if (objectLower && queryLower.includes(objectLower)) {
score += 80;
}
// Term matches in name
for (const term of queryTerms) {
if (nameLower.includes(term)) {
score += 30;
@@ -193,7 +104,6 @@ export class ToolRegistryService {
}
}
// Operation keyword matches
const operations = ['find', 'create', 'update', 'delete', 'search'];
for (const op of operations) {
@@ -220,346 +130,55 @@ export class ToolRegistryService {
names: string[],
context: ToolContext,
): Promise<ToolSet> {
const tools: ToolSet = {};
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [context.roleId],
};
const fullContext = this.buildContext(context.workspaceId, context.roleId);
const allTools: ToolSet = {};
for (const name of names) {
const tool = await this.getToolByName(
name,
context.workspaceId,
rolePermissionConfig,
context.actorContext,
);
for (const provider of this.providers) {
if (await provider.isAvailable(fullContext)) {
const tools = await provider.generateTools(fullContext);
if (tool) {
tools[name] = tool;
Object.assign(allTools, tools);
}
}
return tools;
return Object.fromEntries(
names
.filter((name) => name in allTools)
.map((name) => [name, allTools[name]]),
);
}
private async getToolByName(
name: string,
private buildContext(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
actorContext?: ActorMetadata,
): Promise<ToolSet[string] | null> {
const actionTool = this.getActionToolByName(name, workspaceId);
roleId: string,
): ToolProviderContext {
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [roleId],
};
if (actionTool) {
return actionTool;
}
return {
workspaceId,
roleId,
rolePermissionConfig,
};
}
const workflowTool = await this.getWorkflowToolByName(
private toolSetToIndex(
tools: ToolSet,
category: ToolCategory,
): ToolIndexEntry[] {
const categoryMap: Record<ToolCategory, ToolIndexEntry['category']> = {
DATABASE_CRUD: 'database',
ACTION: 'action',
WORKFLOW: 'workflow',
METADATA: 'metadata',
NATIVE_MODEL: 'action',
};
return Object.entries(tools).map(([name, tool]) => ({
name,
workspaceId,
rolePermissionConfig,
);
if (workflowTool) {
return workflowTool;
}
const match = name.match(
/^(find|find_one|create|update|soft_delete)_(.+)$/,
);
if (match) {
const [, _operation, objectName] = match;
const dbTools = await this.getDatabaseToolsForObject(
workspaceId,
rolePermissionConfig,
objectName,
actorContext,
);
return dbTools[name] ?? null;
}
return null;
}
private async getWorkflowToolByName(
name: string,
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): Promise<ToolSet[string] | null> {
if (!this.workflowToolService) {
return null;
}
const isWorkflowTool = WORKFLOW_TOOLS_METADATA.some(
(tool) => tool.name === name,
);
if (!isWorkflowTool) {
return null;
}
// Generate workflow tools and return the requested one
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
rolePermissionConfig,
);
return workflowTools[name] ?? null;
}
private async getActionToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [roleId],
};
// HTTP Request tool
const hasHttpPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
PermissionFlagType.HTTP_REQUEST_TOOL,
);
if (hasHttpPermission) {
index.push({
name: 'http_request',
description: this.httpTool.description,
category: 'action',
});
}
// Send Email tool
const hasEmailPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
PermissionFlagType.SEND_EMAIL_TOOL,
);
if (hasEmailPermission) {
index.push({
name: 'send_email',
description: this.sendEmailTool.description,
category: 'action',
});
}
index.push({
name: 'search_articles',
description: this.searchArticlesTool.description,
category: 'action',
});
return index;
}
private getActionToolByName(
name: string,
workspaceId: string,
): ToolSet[string] | null {
switch (name) {
case 'http_request':
return {
description: this.httpTool.description,
inputSchema: this.httpTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.httpTool.inputSchema>['input'];
}) => this.httpTool.execute(parameters.input, workspaceId),
};
case 'send_email':
return {
description: this.sendEmailTool.description,
inputSchema: this.sendEmailTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.sendEmailTool.inputSchema>['input'];
}) => this.sendEmailTool.execute(parameters.input, workspaceId),
};
case 'search_articles':
return {
description: this.searchArticlesTool.description,
inputSchema: this.searchArticlesTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.searchArticlesTool.inputSchema>['input'];
}) => this.searchArticlesTool.execute(parameters.input),
};
default:
return null;
}
}
private async getDatabaseToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
const objectPermissions = rolesPermissions[roleId];
if (!objectPermissions) {
return index;
}
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
continue;
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
continue;
}
const objectName = flatObject.nameSingular;
const objectLabel = flatObject.labelSingular;
if (permission.canReadObjectRecords) {
index.push({
name: `find_${objectName}`,
description: `Search and find ${objectLabel} records`,
category: 'database',
objectName,
operation: 'find',
});
index.push({
name: `find_one_${objectName}`,
description: `Get a single ${objectLabel} record by ID`,
category: 'database',
objectName,
operation: 'find_one',
});
}
if (permission.canUpdateObjectRecords) {
index.push({
name: `create_${objectName}`,
description: `Create new ${objectLabel} records`,
category: 'database',
objectName,
operation: 'create',
});
index.push({
name: `update_${objectName}`,
description: `Update existing ${objectLabel} records`,
category: 'database',
objectName,
operation: 'update',
});
}
if (permission.canSoftDeleteObjectRecords) {
index.push({
name: `soft_delete_${objectName}`,
description: `Soft delete ${objectLabel} records`,
category: 'database',
objectName,
operation: 'soft_delete',
});
}
}
return index;
}
private async getDatabaseToolsForObject(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
objectName: string,
actorContext?: ActorMetadata,
): Promise<ToolSet> {
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
let objectPermissions;
if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
objectPermissions =
allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
} else {
return {};
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const flatObject = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.find((obj) => obj.nameSingular === objectName);
if (!flatObject) {
return {};
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
return {};
}
const objectMetadata = {
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
};
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
return factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId,
rolePermissionConfig,
actorContext,
},
);
description: tool.description ?? '',
category: categoryMap[category],
}));
}
}
@@ -2,6 +2,11 @@ import { forwardRef, Module } from '@nestjs/common';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
@@ -28,7 +33,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
ToolGeneratorModule,
RecordCrudModule,
AiModelsModule,
// forwardRef needed: AiAgentExecutionModule imports ToolProviderModule
forwardRef(() => AiAgentExecutionModule),
ObjectMetadataModule,
FieldMetadataModule,
@@ -36,7 +40,34 @@ import { ToolRegistryService } from './services/tool-registry.service';
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [ToolProviderService, ToolRegistryService],
providers: [
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
WorkflowToolProvider,
{
provide: TOOL_PROVIDERS,
useFactory: (
actionProvider: ActionToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
actionProvider,
databaseProvider,
metadataProvider,
workflowProvider,
],
inject: [
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
WorkflowToolProvider,
],
},
ToolProviderService,
ToolRegistryService,
],
exports: [ToolProviderService, ToolRegistryService],
})
export class ToolProviderModule {}
@@ -1,5 +1,5 @@
export enum ToolType {
HTTP_REQUEST = 'HTTP_REQUEST',
SEND_EMAIL = 'SEND_EMAIL',
SEARCH_ARTICLES = 'SEARCH_ARTICLES',
SEARCH_HELP_CENTER = 'SEARCH_HELP_CENTER',
}
@@ -4,10 +4,10 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class ToolRegistryService {
@@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { 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 { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
@@ -14,7 +14,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
TypeOrmModule.forFeature([FileEntity]),
FileModule,
],
providers: [HttpTool, SendEmailTool, SearchArticlesTool],
exports: [HttpTool, SendEmailTool, SearchArticlesTool],
providers: [HttpTool, SendEmailTool, SearchHelpCenterTool],
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool],
})
export class ToolModule {}
@@ -1,18 +1,20 @@
import { z } from 'zod';
export const SearchArticlesInputZodSchema = z.object({
export const SearchHelpCenterInputZodSchema = z.object({
query: z
.string()
.describe('The search query to find relevant help articles about Twenty'),
});
export const SearchArticlesToolParametersZodSchema = z.object({
export const SearchHelpCenterToolParametersZodSchema = z.object({
loadingMessage: z
.string()
.describe(
'A clear, human-readable status message describing the search being performed. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., "Searching help articles for..."). Explain what you are searching for in natural language.',
'A clear, human-readable status message describing the search being performed. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., "Searching help center for..."). Explain what you are searching for in natural language.',
),
input: SearchArticlesInputZodSchema,
input: SearchHelpCenterInputZodSchema,
});
export type SearchArticlesInput = z.infer<typeof SearchArticlesInputZodSchema>;
export type SearchHelpCenterInput = z.infer<
typeof SearchHelpCenterInputZodSchema
>;
@@ -2,17 +2,17 @@ import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { SearchArticlesToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool.schema';
import { SearchHelpCenterToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class SearchArticlesTool implements Tool {
export class SearchHelpCenterTool implements Tool {
description =
'Search Twenty documentation and help articles to find information about features, setup, usage, and troubleshooting.';
inputSchema = SearchArticlesToolParametersZodSchema;
'Search Twenty documentation and help center to find information about features, setup, usage, and troubleshooting.';
inputSchema = SearchHelpCenterToolParametersZodSchema;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -46,14 +46,14 @@ export class SearchArticlesTool implements Tool {
if (results.length === 0) {
return {
success: true,
message: `No help articles found for "${query}"`,
message: `No help center articles found for "${query}"`,
result: [],
};
}
return {
success: true,
message: `Found ${results.length} relevant help article${results.length === 1 ? '' : 's'} for "${query}"`,
message: `Found ${results.length} relevant help center article${results.length === 1 ? '' : 's'} for "${query}"`,
result: results,
};
} catch (error) {
@@ -61,11 +61,11 @@ export class SearchArticlesTool implements Tool {
? error.response?.data?.message || error.message
: error instanceof Error
? error.message
: 'Documentation search failed';
: 'Help center search failed';
return {
success: false,
message: `Failed to search help articles for "${query}"`,
message: `Failed to search help center for "${query}"`,
error: errorDetail,
};
}
@@ -5,7 +5,7 @@ import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
@@ -0,0 +1,13 @@
export type BrowsingContextType =
| {
type: 'recordPage';
objectNameSingular: string;
recordId: string;
}
| {
type: 'listView';
objectNameSingular: string;
viewId: string;
viewName: string;
filterDescriptions: string[];
};
@@ -1,4 +0,0 @@
export type RecordIdsByObjectMetadataNameSingularType = Array<{
objectMetadataNameSingular: string;
recordIds: string[];
}>;
@@ -18,7 +18,7 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatStreamingService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat-streaming.service';
@Controller('rest/agent-chat')
@@ -36,7 +36,7 @@ export class AgentChatController {
body: {
threadId: string;
messages: ExtendedUIMessage[];
recordIdsByObjectMetadataNameSingular?: RecordIdsByObjectMetadataNameSingularType;
browsingContext?: BrowsingContextType | null;
},
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@@ -45,8 +45,7 @@ export class AgentChatController {
this.agentStreamingService.streamAgentChat({
threadId: body.threadId,
messages: body.messages,
recordIdsByObjectMetadataNameSingular:
body.recordIdsByObjectMetadataNameSingular ?? [],
browsingContext: body.browsingContext ?? null,
userWorkspaceId,
workspace,
response,
@@ -12,7 +12,7 @@ import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatService } from './agent-chat.service';
@@ -24,7 +24,7 @@ export type StreamAgentChatOptions = {
workspace: WorkspaceEntity;
response: Response;
messages: ExtendedUIMessage[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
browsingContext: BrowsingContextType | null;
};
@Injectable()
@@ -43,7 +43,7 @@ export class AgentChatStreamingService {
userWorkspaceId,
workspace,
messages,
recordIdsByObjectMetadataNameSingular,
browsingContext,
response,
}: StreamAgentChatOptions) {
const thread = await this.threadRepository.findOne({
@@ -67,7 +67,7 @@ export class AgentChatStreamingService {
workspace,
userWorkspaceId,
messages,
recordIdsByObjectMetadataNameSingular,
browsingContext,
});
// Write initial status
@@ -13,9 +13,7 @@ import {
} from 'ai';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import {
type ToolIndexEntry,
@@ -30,29 +28,22 @@ import {
} from 'src/engine/core-modules/tool-provider/tools';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { 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 { ModelProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { getWorkspaceContext } from 'src/engine/twenty-orm/storage/orm-workspace-context.storage';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
export type ChatExecutionOptions = {
workspace: WorkspaceEntity;
userWorkspaceId: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
browsingContext: BrowsingContextType | null;
};
export type ChatExecutionResult = {
@@ -64,7 +55,7 @@ export type ChatExecutionResult = {
const INITIAL_AGENTS_LIMIT = 2;
// Common tools to pre-load for quick access
const COMMON_PRELOAD_TOOLS = ['http_request', 'search_articles'];
const COMMON_PRELOAD_TOOLS = ['http_request', 'search_help_center'];
@Injectable()
export class ChatExecutionService {
@@ -76,7 +67,6 @@ export class ChatExecutionService {
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly workspaceDomainsService: WorkspaceDomainsService,
) {}
@@ -84,7 +74,7 @@ export class ChatExecutionService {
workspace,
userWorkspaceId,
messages,
recordIdsByObjectMetadataNameSingular,
browsingContext,
}: ChatExecutionOptions): Promise<ChatExecutionResult> {
const { actorContext, roleId } =
await this.agentActorContextService.buildUserAndAgentActorContext(
@@ -96,15 +86,9 @@ export class ChatExecutionService {
const lastUserMessage = this.getLastUserMessage(messages);
let recordContext: string | undefined;
if (recordIdsByObjectMetadataNameSingular.length > 0) {
recordContext = await this.buildContextFromRecords(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
);
}
const contextString = browsingContext
? this.buildContextFromBrowsingContext(workspace, browsingContext)
: undefined;
const [toolCatalog, initialAgents] = await Promise.all([
this.toolRegistry.buildToolIndex(workspace.id, roleId),
@@ -157,7 +141,7 @@ export class ChatExecutionService {
toolCatalog,
initialAgents,
preloadedToolNames,
recordContext,
contextString,
);
this.logger.log(
@@ -207,121 +191,58 @@ export class ChatExecutionService {
};
}
private async buildContextFromRecords(
private buildContextFromBrowsingContext(
workspace: WorkspaceEntity,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
userWorkspaceId: string,
): Promise<string> {
const authContext = buildSystemAuthContext(workspace.id);
const contextFromRecords =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
authContext,
async () => {
const {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
permissionsPerRoleId: objectMetadataPermissions,
userWorkspaceRoleMap,
} = getWorkspaceContext();
const roleId = userWorkspaceRoleMap[userWorkspaceId];
if (!roleId) {
throw new AgentException(
'Failed to retrieve user role.',
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getDataSourceForWorkspace(
workspace.id,
);
const contextObject = (
await Promise.all(
recordIdsByObjectMetadataNameSingular.map(
async (recordsWithObjectMetadataNameSingular) => {
if (
recordsWithObjectMetadataNameSingular.recordIds.length === 0
) {
return [];
}
const objectMetadataId =
objectIdByNameSingular[
recordsWithObjectMetadataNameSingular
.objectMetadataNameSingular
];
const objectMetadataMapItem = objectMetadataId
? flatObjectMetadataMaps.byId[objectMetadataId]
: undefined;
if (!objectMetadataMapItem) {
this.logger.warn(
`Object metadata not found for ${recordsWithObjectMetadataNameSingular.objectMetadataNameSingular}`,
);
return [];
}
const repository = workspaceDataSource.getRepository(
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
{ unionOf: [roleId] },
);
const restrictedFields =
objectMetadataPermissions?.[roleId]?.[
objectMetadataMapItem.id
]?.restrictedFields ?? {};
const hasRestrictedFields = Object.values(
restrictedFields,
).some((field) => field.canRead === false);
const selectOptions = hasRestrictedFields
? getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem,
flatFieldMetadataMaps,
},
})
: undefined;
return (
await repository.find({
...(selectOptions && { select: selectOptions }),
where: {
id: In(recordsWithObjectMetadataNameSingular.recordIds),
},
})
).map((record) => {
return {
...record,
resourceUrl:
this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getAppPath(AppPath.RecordShowPage, {
objectNameSingular:
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
objectRecordId: record.id,
}),
}),
};
});
},
),
)
).flat(2);
return JSON.stringify(contextObject);
},
browsingContext: BrowsingContextType,
): string {
if (browsingContext.type === 'recordPage') {
return this.buildRecordPageContext(
workspace,
browsingContext.objectNameSingular,
browsingContext.recordId,
);
}
return contextFromRecords;
if (browsingContext.type === 'listView') {
return this.buildListViewContext(browsingContext);
}
return '';
}
private buildRecordPageContext(
workspace: WorkspaceEntity,
objectNameSingular: string,
recordId: string,
): string {
const resourceUrl = this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getAppPath(AppPath.RecordShowPage, {
objectNameSingular,
objectRecordId: recordId,
}),
});
return `The user is viewing a ${objectNameSingular} record (ID: ${recordId}, URL: ${resourceUrl}). Use tools to fetch record details if needed.`;
}
private buildListViewContext(browsingContext: {
type: 'listView';
objectNameSingular: string;
viewId: string;
viewName: string;
filterDescriptions: string[];
}): string {
const { objectNameSingular, viewName, filterDescriptions } =
browsingContext;
let context = `The user is viewing a list of ${objectNameSingular} records in a view called "${viewName}".`;
if (filterDescriptions.length > 0) {
context += `\nFilters applied: ${filterDescriptions.join(', ')}`;
}
return context;
}
private getLastUserMessage(
@@ -346,7 +267,7 @@ export class ChatExecutionService {
toolCatalog: ToolIndexEntry[],
agents: AgentEntity[],
preloadedTools: string[],
recordContext?: string,
contextString?: string,
): string {
const parts: string[] = [
CHAT_SYSTEM_PROMPTS.BASE,
@@ -363,9 +284,9 @@ export class ChatExecutionService {
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
if (recordContext) {
if (contextString) {
parts.push(
`\nCONTEXT (records the user is currently viewing):\n${recordContext}`,
`\nCONTEXT (what the user is currently viewing):\n${contextString}`,
);
}
@@ -12,7 +12,7 @@ export const HELPER_AGENT: StandardAgentDefinition = {
prompt: `You are a Helper Agent for Twenty. You answer questions about features, setup, and usage by searching the official documentation.
Core workflow:
1. Use searchArticles tool to find relevant documentation
1. Use search_help_center tool to find relevant documentation
2. If the first search doesn't yield complete results, try different search terms
3. Synthesize information from multiple articles when needed
4. Provide clear, step-by-step answers based on the documentation