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:
@@ -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>
|
||||
</>
|
||||
|
||||
-14
@@ -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} />;
|
||||
};
|
||||
+8
-15
@@ -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>
|
||||
);
|
||||
|
||||
-102
@@ -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"
|
||||
|
||||
-10
@@ -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[];
|
||||
};
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const TOOL_PROVIDERS = Symbol('TOOL_PROVIDERS');
|
||||
+20
@@ -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>;
|
||||
}
|
||||
+80
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+148
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
+49
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -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
|
||||
},
|
||||
],
|
||||
|
||||
+58
-439
@@ -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],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
+33
-2
@@ -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',
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 {}
|
||||
|
||||
+7
-5
@@ -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
|
||||
>;
|
||||
+8
-8
@@ -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,
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
export type BrowsingContextType =
|
||||
| {
|
||||
type: 'recordPage';
|
||||
objectNameSingular: string;
|
||||
recordId: string;
|
||||
}
|
||||
| {
|
||||
type: 'listView';
|
||||
objectNameSingular: string;
|
||||
viewId: string;
|
||||
viewName: string;
|
||||
filterDescriptions: string[];
|
||||
};
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
export type RecordIdsByObjectMetadataNameSingularType = Array<{
|
||||
objectMetadataNameSingular: string;
|
||||
recordIds: string[];
|
||||
}>;
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+60
-139
@@ -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}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user