feat: improve AI chat - system prompt, tool output, context window display (#17769)

⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-09 14:26:02 +01:00
committed by GitHub
parent 6c7c389785
commit 3216b634a3
105 changed files with 3245 additions and 1714 deletions
File diff suppressed because one or more lines are too long
+19 -2
View File
@@ -21,6 +21,19 @@ export type Scalars = {
Upload: any;
};
export type AiSystemPromptPreview = {
__typename?: 'AISystemPromptPreview';
estimatedTokenCount: Scalars['Int'];
sections: Array<AiSystemPromptSection>;
};
export type AiSystemPromptSection = {
__typename?: 'AISystemPromptSection';
content: Scalars['String'];
estimatedTokenCount: Scalars['Int'];
title: Scalars['String'];
};
export type ActivateWorkspaceInput = {
displayName?: InputMaybe<Scalars['String']>;
};
@@ -76,12 +89,13 @@ export type Agent = {
export type AgentChatThread = {
__typename?: 'AgentChatThread';
contextWindowTokens?: Maybe<Scalars['Int']>;
conversationSize: Scalars['Int'];
createdAt: Scalars['DateTime'];
id: Scalars['UUID'];
title?: Maybe<Scalars['String']>;
totalInputCredits: Scalars['Int'];
totalInputCredits: Scalars['Float'];
totalInputTokens: Scalars['Int'];
totalOutputCredits: Scalars['Int'];
totalOutputCredits: Scalars['Float'];
totalOutputTokens: Scalars['Int'];
updatedAt: Scalars['DateTime'];
};
@@ -2039,6 +2053,7 @@ export enum MessageChannelVisibility {
export enum ModelProvider {
ANTHROPIC = 'ANTHROPIC',
GROQ = 'GROQ',
NONE = 'NONE',
OPENAI = 'OPENAI',
OPENAI_COMPATIBLE = 'OPENAI_COMPATIBLE',
@@ -4759,6 +4774,7 @@ export type UpdateWorkflowVersionStepInput = {
};
export type UpdateWorkspaceInput = {
aiAdditionalInstructions?: InputMaybe<Scalars['String']>;
allowImpersonation?: InputMaybe<Scalars['Boolean']>;
customDomain?: InputMaybe<Scalars['String']>;
defaultRoleId?: InputMaybe<Scalars['UUID']>;
@@ -5146,6 +5162,7 @@ export type WorkflowVersionStepChanges = {
export type Workspace = {
__typename?: 'Workspace';
activationStatus: WorkspaceActivationStatus;
aiAdditionalInstructions?: Maybe<Scalars['String']>;
allowImpersonation: Scalars['Boolean'];
billingEntitlements: Array<BillingEntitlement>;
billingSubscriptions: Array<BillingSubscription>;
@@ -30,6 +30,7 @@ const StyledEditorContainer = styled.div<{
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
@@ -54,15 +55,15 @@ const StyledEditorContainer = styled.div<{
}
h1 {
font-size: 32px;
font-size: 1.5em;
}
h2 {
font-size: 24px;
font-size: 1.3em;
}
h3 {
font-size: 16px;
font-size: 1.1em;
}
li {
@@ -84,17 +84,18 @@ export const AIChatThreadGroup = ({
const handleThreadClick = (thread: AgentChatThread) => {
setCurrentAIChatThread(thread.id);
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData =
totalTokens > 0 && isDefined(thread.contextWindowTokens);
(thread.conversationSize ?? 0) > 0 &&
isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -9,7 +9,10 @@ import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { CodeExecutionDisplay } from '@/ai/components/CodeExecutionDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { getToolIcon } from '@/ai/utils/getToolIcon';
import { getToolDisplayMessage } from '@/ai/utils/getWebSearchToolDisplayMessage';
import {
getToolDisplayMessage,
resolveToolInput,
} from '@/ai/utils/getToolDisplayMessage';
import { useLingui } from '@lingui/react/macro';
import { type ToolUIPart } from 'ai';
import { isDefined } from 'twenty-shared/utils';
@@ -132,12 +135,10 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
const [activeTab, setActiveTab] = useState<TabType>('output');
const { input, output, type, errorText } = toolPart;
const toolName = type.split('-')[1];
const rawToolName = type.split('-')[1];
const toolInput =
isDefined(input) && typeof input === 'object' && 'input' in input
? input.input
: input;
const { resolvedInput: toolInput, resolvedToolName: toolName } =
resolveToolInput(input, rawToolName);
const hasError = isDefined(errorText);
const isExpandable = isDefined(output) || hasError;
@@ -175,7 +176,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
<StyledLoadingContainer>
<ShimmeringText>
<StyledDisplayMessage>
{getToolDisplayMessage(input, toolName, false)}
{getToolDisplayMessage(input, rawToolName, false)}
</StyledDisplayMessage>
</ShimmeringText>
</StyledLoadingContainer>
@@ -188,19 +189,32 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
);
}
// For execute_tool, the actual result is nested inside output.result
const unwrappedOutput =
rawToolName === 'execute_tool' &&
isDefined(output) &&
typeof output === 'object' &&
'result' in output
? (output as { result: unknown }).result
: output;
const displayMessage = hasError
? t`Tool execution failed`
: output &&
typeof output === 'object' &&
'message' in output &&
typeof output.message === 'string'
? output.message
: getToolDisplayMessage(input, toolName, true);
: rawToolName === 'learn_tools' || rawToolName === 'execute_tool'
? getToolDisplayMessage(input, rawToolName, true)
: unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'message' in unwrappedOutput &&
typeof unwrappedOutput.message === 'string'
? unwrappedOutput.message
: getToolDisplayMessage(input, rawToolName, true);
const result =
output && typeof output === 'object' && 'result' in output
? (output as { result: string }).result
: output;
unwrappedOutput &&
typeof unwrappedOutput === 'object' &&
'result' in unwrappedOutput
? (unwrappedOutput as { result: string }).result
: unwrappedOutput;
const ToolIcon = getToolIcon(toolName);
@@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
usage: {
inputTokens: 1250,
outputTokens: 890,
cachedInputTokens: 0,
inputCredits: 12,
outputCredits: 8,
conversationSize: 1250,
},
},
};
@@ -1,12 +1,17 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { ProgressBar } from 'twenty-ui/feedback';
import { ContextUsageProgressRing } from '@/ai/components/internal/ContextUsageProgressRing';
import { agentChatUsageState } from '@/ai/states/agentChatUsageState';
import {
agentChatUsageState,
type AgentChatLastMessageUsage,
} from '@/ai/states/agentChatUsageState';
const StyledContainer = styled.div`
position: relative;
@@ -41,14 +46,14 @@ const StyledHoverCard = styled.div`
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
min-width: 240px;
min-width: 280px;
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: ${({ theme }) => theme.lastLayerZIndex};
`;
const StyledHeader = styled.div`
const StyledSection = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
@@ -61,14 +66,6 @@ const StyledRow = styled.div`
justify-content: space-between;
`;
const StyledBody = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(3)};
padding-top: 0;
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
@@ -79,15 +76,16 @@ const StyledValue = styled.span`
font-size: ${({ theme }) => theme.font.size.sm};
`;
const StyledFooter = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.secondary};
const StyledSectionTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const StyledDivider = styled.div`
border-top: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: 0 0 ${({ theme }) => theme.border.radius.md}
${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: space-between;
padding: ${({ theme }) => theme.spacing(3)};
`;
const formatTokenCount = (count: number): string => {
@@ -103,6 +101,31 @@ const formatTokenCount = (count: number): string => {
return count.toString();
};
const formatCredits = (credits: number): string => {
// Credits are already in display units from the API (internal / 1000)
// Show up to 1 decimal for fractional values, none for whole numbers
if (Number.isInteger(credits)) {
return credits.toLocaleString();
}
return credits.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: 1,
});
};
const getCachedLabel = (lastMessage: AgentChatLastMessageUsage): string => {
if (lastMessage.cachedInputTokens <= 0 || lastMessage.inputTokens <= 0) {
return '';
}
const cachedPercent = Math.round(
(lastMessage.cachedInputTokens / lastMessage.inputTokens) * 100,
);
return ` (${t`${cachedPercent}% cached`})`;
};
export const AIChatContextUsageButton = () => {
const { t } = useLingui();
const theme = useTheme();
@@ -121,14 +144,14 @@ export const AIChatContextUsageButton = () => {
}
const percentage = Math.min(
(agentChatUsage.totalTokens / agentChatUsage.contextWindowTokens) * 100,
(agentChatUsage.conversationSize / agentChatUsage.contextWindowTokens) *
100,
100,
);
const formattedPercentage = percentage.toFixed(1);
const totalCredits =
agentChatUsage.inputCredits + agentChatUsage.outputCredits;
const inputCredits = agentChatUsage.inputCredits.toLocaleString();
const outputCredits = agentChatUsage.outputCredits.toLocaleString();
const lastMessage = agentChatUsage.lastMessage;
return (
<StyledContainer
@@ -142,12 +165,13 @@ export const AIChatContextUsageButton = () => {
{isHovered && (
<StyledHoverCard>
<StyledHeader>
<StyledSection>
<StyledRow>
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
<StyledValue>
{formatTokenCount(agentChatUsage.totalTokens)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}
{formatTokenCount(agentChatUsage.conversationSize)} /{' '}
{formatTokenCount(agentChatUsage.contextWindowTokens)}{' '}
{t`tokens`}
</StyledValue>
</StyledRow>
<ProgressBar
@@ -162,29 +186,61 @@ export const AIChatContextUsageButton = () => {
backgroundColor={theme.background.quaternary}
withBorderRadius
/>
</StyledHeader>
</StyledSection>
<StyledBody>
{isDefined(lastMessage) && (
<>
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Last message`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.inputTokens)}
{getCachedLabel(lastMessage)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(lastMessage.outputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Cost`}</StyledLabel>
<StyledValue>
{formatCredits(
lastMessage.inputCredits + lastMessage.outputCredits,
)}{' '}
{t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</>
)}
<StyledDivider />
<StyledSection>
<StyledSectionTitle>{t`Conversation`}</StyledSectionTitle>
<StyledRow>
<StyledLabel>{t`Input`}</StyledLabel>
<StyledLabel>{t`Input tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.inputTokens)} {' '}
{t`${inputCredits} credits`}
{formatTokenCount(agentChatUsage.inputTokens)}
</StyledValue>
</StyledRow>
<StyledRow>
<StyledLabel>{t`Output`}</StyledLabel>
<StyledLabel>{t`Output tokens`}</StyledLabel>
<StyledValue>
{formatTokenCount(agentChatUsage.outputTokens)} {' '}
{t`${outputCredits} credits`}
{formatTokenCount(agentChatUsage.outputTokens)}
</StyledValue>
</StyledRow>
</StyledBody>
<StyledFooter>
<StyledLabel>{t`Total credits`}</StyledLabel>
<StyledPercentage>{totalCredits.toLocaleString()}</StyledPercentage>
</StyledFooter>
<StyledRow>
<StyledLabel>{t`Total cost`}</StyledLabel>
<StyledValue>
{formatCredits(totalCredits)} {t`credits`}
</StyledValue>
</StyledRow>
</StyledSection>
</StyledHoverCard>
)}
</StyledContainer>
@@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt
@@ -119,8 +119,10 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
type UsageMetadata = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
conversationSize: number;
};
type ModelMetadata = {
contextWindowTokens: number;
@@ -133,11 +135,17 @@ export const useAgentChat = (uiMessages: ExtendedUIMessage[]) => {
if (isDefined(usage) && isDefined(model)) {
setAgentChatUsage((prev) => ({
lastMessage: {
inputTokens: usage.inputTokens,
outputTokens: usage.outputTokens,
cachedInputTokens: usage.cachedInputTokens,
inputCredits: usage.inputCredits,
outputCredits: usage.outputCredits,
},
conversationSize: usage.conversationSize,
contextWindowTokens: model.contextWindowTokens,
inputTokens: (prev?.inputTokens ?? 0) + usage.inputTokens,
outputTokens: (prev?.outputTokens ?? 0) + usage.outputTokens,
totalTokens:
(prev?.totalTokens ?? 0) + usage.inputTokens + usage.outputTokens,
contextWindowTokens: model.contextWindowTokens,
inputCredits: (prev?.inputCredits ?? 0) + usage.inputCredits,
outputCredits: (prev?.outputCredits ?? 0) + usage.outputCredits,
}));
@@ -23,16 +23,17 @@ const setUsageFromThread = (
thread: AgentChatThread,
setAgentChatUsage: SetterOrUpdater<AgentChatUsageState | null>,
) => {
const totalTokens = thread.totalInputTokens + thread.totalOutputTokens;
const hasUsageData = totalTokens > 0 && isDefined(thread.contextWindowTokens);
const hasUsageData =
(thread.conversationSize ?? 0) > 0 && isDefined(thread.contextWindowTokens);
setAgentChatUsage(
hasUsageData
? {
lastMessage: null,
conversationSize: thread.conversationSize ?? 0,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputTokens: thread.totalInputTokens,
outputTokens: thread.totalOutputTokens,
totalTokens,
contextWindowTokens: thread.contextWindowTokens ?? 0,
inputCredits: thread.totalInputCredits,
outputCredits: thread.totalOutputCredits,
}
@@ -1,10 +1,19 @@
import { atom } from 'recoil';
export type AgentChatUsageState = {
export type AgentChatLastMessageUsage = {
inputTokens: number;
outputTokens: number;
totalTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
};
export type AgentChatUsageState = {
lastMessage: AgentChatLastMessageUsage | null;
conversationSize: number;
contextWindowTokens: number;
inputTokens: number;
outputTokens: number;
inputCredits: number;
outputCredits: number;
};
@@ -8,6 +8,7 @@ describe('groupThreadsByDate', () => {
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
};
@@ -0,0 +1,99 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const resolveToolInput = (
input: ToolInput,
toolName: string,
): { resolvedInput: ToolInput; resolvedToolName: string } => {
if (
toolName === 'execute_tool' &&
isDefined(input) &&
typeof input === 'object' &&
'toolName' in input &&
'arguments' in input
) {
return {
resolvedInput: input.arguments as ToolInput,
resolvedToolName: String(input.toolName),
};
}
return { resolvedInput: input, resolvedToolName: toolName };
};
const extractLearnToolNames = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'toolNames' in input &&
Array.isArray(input.toolNames)
) {
return input.toolNames.join(', ');
}
return '';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
const { resolvedInput, resolvedToolName } = resolveToolInput(input, toolName);
if (resolvedToolName === 'web_search') {
const query = extractSearchQuery(resolvedInput);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
if (resolvedToolName === 'learn_tools') {
const names = extractLearnToolNames(resolvedInput);
const action = isFinished ? 'Learned' : 'Learning';
return names ? `${action} ${names}` : `${action} tools...`;
}
return extractLoadingMessage(resolvedInput);
};
@@ -1,6 +1,16 @@
import { IconDatabase, IconMail, IconTool, IconWorld } from 'twenty-ui/display';
import {
IconBook2,
IconDatabase,
IconMail,
IconTool,
IconWorld,
} from 'twenty-ui/display';
const TOOL_ICON_MAPPINGS = [
{
keywords: ['learn_tools'],
icon: IconBook2,
},
{
keywords: ['email'],
icon: IconMail,
@@ -1,56 +0,0 @@
import { type ToolInput } from '@/ai/types/ToolInput';
import { isDefined } from 'twenty-shared/utils';
const extractSearchQuery = (input: ToolInput): string => {
if (!input) {
return '';
}
if (
typeof input === 'object' &&
'query' in input &&
typeof input.query === 'string'
) {
return input.query;
}
if (
typeof input === 'object' &&
'action' in input &&
isDefined(input.action) &&
typeof input.action === 'object' &&
'query' in input.action &&
typeof input.action.query === 'string'
) {
return input.action.query;
}
return '';
};
const extractLoadingMessage = (input: ToolInput): string => {
if (
isDefined(input) &&
typeof input === 'object' &&
'loadingMessage' in input &&
typeof input.loadingMessage === 'string'
) {
return input.loadingMessage;
}
return 'Processing...';
};
export const getToolDisplayMessage = (
input: ToolInput,
toolName: string,
isFinished?: boolean,
): string => {
if (toolName === 'web_search') {
const query = extractSearchQuery(input);
const action = isFinished ? 'Searched' : 'Searching';
return query ? `${action} the web for '${query}'` : `${action} the web`;
}
return extractLoadingMessage(input);
};
@@ -185,6 +185,12 @@ const SettingsSkillForm = lazy(() =>
})),
);
const SettingsAIPrompts = lazy(() =>
import('~/pages/settings/ai/SettingsAIPrompts').then((module) => ({
default: module.SettingsAIPrompts,
})),
);
const SettingsWorkspaceMembers = lazy(() =>
import('~/pages/settings/members/SettingsWorkspaceMembers').then(
(module) => ({
@@ -442,6 +448,7 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
element={<SettingsApiWebhooks />}
/>
<Route path={SettingsPath.AI} element={<SettingsAI />} />
<Route path={SettingsPath.AIPrompts} element={<SettingsAIPrompts />} />
<Route
path={SettingsPath.AINewAgent}
element={<SettingsAgentForm mode="create" />}
@@ -36,6 +36,7 @@ export type CurrentWorkspace = Pick<
| 'eventLogRetentionDays'
| 'fastModel'
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
@@ -88,6 +88,7 @@ export const USER_QUERY_FRAGMENT = gql`
}
fastModel
smartModel
aiAdditionalInstructions
isTwoFactorAuthenticationEnforced
trashRetentionDays
eventLogRetentionDays
@@ -0,0 +1,14 @@
import { gql } from '@apollo/client';
export const GET_AI_SYSTEM_PROMPT_PREVIEW = gql`
query GetAISystemPromptPreview {
getAISystemPromptPreview {
sections {
title
content
estimatedTokenCount
}
estimatedTokenCount
}
}
`;
@@ -1,3 +1,7 @@
import styled from '@emotion/styled';
import { Link } from 'react-router-dom';
import { SettingsOptionCardContentButton } from '@/settings/components/SettingsOptions/SettingsOptionCardContentButton';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
@@ -7,13 +11,25 @@ import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import { IconSettings, IconSparkles, IconTool } from 'twenty-ui/display';
import {
H2Title,
IconFileText,
IconSettings,
IconSparkles,
IconTool,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Card, Section } from 'twenty-ui/layout';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings';
import { SettingsSkillsTable } from './components/SettingsSkillsTable';
import { SettingsToolsTable } from './components/SettingsToolsTable';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
const StyledLink = styled(Link)`
text-decoration: none;
`;
export const SettingsAI = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -63,6 +79,28 @@ export const SettingsAI = () => {
{isSettingsTab && (
<>
<SettingsAIRouterSettings />
<Section>
<H2Title
title={t`System Prompt`}
description={t`View and customize AI instructions`}
/>
<Card rounded>
<SettingsOptionCardContentButton
Icon={IconFileText}
title={t`System Prompt`}
description={t`View the AI system prompt and add custom instructions`}
Button={
<StyledLink to={getSettingsPath(SettingsPath.AIPrompts)}>
<Button
title={t`Configure`}
variant="secondary"
size="small"
/>
</StyledLink>
}
/>
</Card>
</Section>
<SettingsAIMCP />
</>
)}
@@ -0,0 +1,263 @@
import { ApolloError } from '@apollo/client';
import styled from '@emotion/styled';
import { useDebouncedCallback } from 'use-debounce';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { Section } from 'twenty-ui/layout';
import {
useGetAiSystemPromptPreviewQuery,
useUpdateWorkspaceMutation,
} from '~/generated-metadata/graphql';
const StyledFormContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(4)};
`;
const StyledTokenBadge = styled.span`
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(0.5)}
${({ theme }) => theme.spacing(1.5)};
white-space: nowrap;
`;
export const SettingsAIPrompts = () => {
const { enqueueErrorSnackBar } = useSnackBar();
const [currentWorkspace, setCurrentWorkspace] = useRecoilState(
currentWorkspaceState,
);
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const [updateWorkspace] = useUpdateWorkspaceMutation();
const { data: previewData, loading: previewLoading } =
useGetAiSystemPromptPreviewQuery();
const [workspaceInstructions, setWorkspaceInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const [originalInstructions, setOriginalInstructions] = useState(
currentWorkspace?.aiAdditionalInstructions ?? '',
);
const handleWorkspaceInstructionsInit = () => {
if (currentWorkspace?.aiAdditionalInstructions !== undefined) {
setWorkspaceInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
setOriginalInstructions(currentWorkspace.aiAdditionalInstructions ?? '');
}
};
if (
currentWorkspace?.aiAdditionalInstructions !== undefined &&
originalInstructions === '' &&
currentWorkspace.aiAdditionalInstructions !== null &&
currentWorkspace.aiAdditionalInstructions !== originalInstructions
) {
handleWorkspaceInstructionsInit();
}
const autoSave = useDebouncedCallback(async (newValue: string) => {
if (!currentWorkspace?.id || newValue === originalInstructions) {
return;
}
try {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: newValue || null,
});
await updateWorkspace({
variables: {
input: {
aiAdditionalInstructions: newValue || null,
},
},
});
setOriginalInstructions(newValue);
} catch (error) {
setCurrentWorkspace({
...currentWorkspace,
aiAdditionalInstructions: originalInstructions || null,
});
if (error instanceof ApolloError) {
enqueueErrorSnackBar({
apolloError: error,
});
} else {
enqueueErrorSnackBar({
message: t`Failed to save workspace instructions`,
});
}
}
}, 1000);
const handleWorkspaceInstructionsChange = (value: string) => {
setWorkspaceInstructions(value);
autoSave(value);
};
const preview = previewData?.getAISystemPromptPreview;
const sections = preview?.sections ?? [];
const buildUserContextPreview = (): string => {
if (!isDefined(currentWorkspaceMember)) {
return '';
}
const parts = [
`**${t`User`}:** ${currentWorkspaceMember.name.firstName} ${currentWorkspaceMember.name.lastName}`.trim(),
`**${t`Locale`}:** ${currentWorkspaceMember.locale ?? 'en'}`,
];
if (isDefined(currentWorkspaceMember.timeZone)) {
parts.push(`**${t`Timezone`}:** ${currentWorkspaceMember.timeZone}`);
}
return parts.join('\n\n');
};
const userContextPreview = buildUserContextPreview();
const promptSections = sections.filter(
(section) =>
section.title !== 'Workspace Instructions' &&
section.title !== 'User Context',
);
const formatTokenCount = (count: number): string => {
if (count >= 1000) {
const kTokens = (count / 1000).toFixed(1);
return t`~${kTokens}k tokens`;
}
return t`~${count} tokens`;
};
const totalTokenCount = isDefined(preview)
? formatTokenCount(preview.estimatedTokenCount)
: '';
const pageTitle = isDefined(preview)
? t`System Prompt (${totalTokenCount})`
: t`System Prompt`;
return (
<SubMenuTopBarContainer
title={pageTitle}
links={[
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{ children: t`AI`, href: getSettingsPath(SettingsPath.AI) },
{ children: t`System Prompt` },
]}
>
<SettingsPageContainer>
{promptSections.map((section) => (
<Section key={section.title}>
<H2Title
title={section.title}
description={t`Read-only — managed by Twenty`}
adornment={
<StyledTokenBadge>
{formatTokenCount(section.estimatedTokenCount)}
</StyledTokenBadge>
}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={
previewLoading ? `loading-${section.title}` : section.title
}
label={section.title}
readonly={true}
defaultValue={section.content}
contentType="markdown"
onChange={() => {}}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: section.title,
},
]}
minHeight={120}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
))}
<Section>
<H2Title
title={t`Workspace Instructions`}
description={t`Add custom instructions specific to your workspace (appended to system prompt)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
key={originalInstructions}
label={t`Additional Instructions`}
readonly={false}
defaultValue={workspaceInstructions}
contentType="markdown"
onChange={handleWorkspaceInstructionsChange}
enableFullScreen={true}
fullScreenBreadcrumbs={[
{
children: t`System Prompt`,
href: '#',
},
{
children: t`Workspace Instructions`,
},
]}
placeholder={t`E.g., "We are a B2B SaaS company. Always use formal language..."`}
minHeight={150}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
<Section>
<H2Title
title={t`User Context`}
description={t`Information about the current user (auto-generated and included in each request)`}
/>
<StyledFormContainer>
<FormAdvancedTextFieldInput
label={t`User Information`}
readonly={true}
defaultValue={userContextPreview}
contentType="markdown"
onChange={() => {}}
enableFullScreen={false}
minHeight={80}
maxWidth={700}
/>
</StyledFormContainer>
</Section>
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
};
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.17",
"@ai-sdk/groq": "^2.0.34",
"@ai-sdk/openai": "^2.0.30",
"@ai-sdk/provider-utils": "^3.0.9",
"@ai-sdk/xai": "^2.0.19",
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddAiAdditionalInstructions1770311652940
implements MigrationInterface
{
name = 'AddAiAdditionalInstructions1770311652940';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "aiAdditionalInstructions" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "aiAdditionalInstructions"`,
);
}
}
@@ -0,0 +1,19 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddConversationSizeToAgentChatThread1770400000000
implements MigrationInterface
{
name = 'AddConversationSizeToAgentChatThread1770400000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" ADD COLUMN "conversationSize" integer NOT NULL DEFAULT 0`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agentChatThread" DROP COLUMN "conversationSize"`,
);
}
}
@@ -23,6 +23,10 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -298,7 +302,17 @@ export class BillingResolver {
async getMeteredProductsUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageOutput[]> {
return await this.billingUsageService.getMeteredProductsUsage(workspace);
const usageData =
await this.billingUsageService.getMeteredProductsUsage(workspace);
return usageData.map((item) => ({
...item,
usedCredits: toDisplayCredits(item.usedCredits),
grantedCredits: toDisplayCredits(item.grantedCredits),
rolloverCredits: toDisplayCredits(item.rolloverCredits),
totalGrantedCredits: toDisplayCredits(item.totalGrantedCredits),
unitPriceCents: item.unitPriceCents * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
}));
}
@Mutation(() => BillingUpdateOutput)
@@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, Float, ObjectType } from '@nestjs/graphql';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@@ -13,18 +13,18 @@ export class BillingMeteredProductUsageOutput {
@Field(() => Date)
periodEnd: Date;
@Field(() => Number)
@Field(() => Float)
usedCredits: number;
@Field(() => Number)
@Field(() => Float)
grantedCredits: number;
@Field(() => Number)
@Field(() => Float)
rolloverCredits: number;
@Field(() => Number)
@Field(() => Float)
totalGrantedCredits: number;
@Field(() => Number)
@Field(() => Float)
unitPriceCents: number;
}
@@ -0,0 +1,9 @@
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
// Display credits are 1000x coarser: $1 = 1,000 display credits
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
// Converts internal (high-precision) credits to user-facing display credits.
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
export const toDisplayCredits = (internalCredits: number): number =>
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;
@@ -3,9 +3,11 @@ import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
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 { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -23,14 +25,18 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
providers: [
CommonApiContextBuilderService,
CreateRecordService,
CreateManyRecordsService,
UpdateRecordService,
UpdateManyRecordsService,
DeleteRecordService,
FindRecordsService,
UpsertRecordService,
],
exports: [
CreateRecordService,
CreateManyRecordsService,
UpdateRecordService,
UpdateManyRecordsService,
DeleteRecordService,
FindRecordsService,
UpsertRecordService,
@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type CreateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/create-many-records-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@Injectable()
export class CreateManyRecordsService {
private readonly logger = new Logger(CreateManyRecordsService.name);
constructor(
private readonly commonCreateManyRunner: CommonCreateManyQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: CreateManyRecordsParams): Promise<ToolOutput> {
const { objectName, objectRecords, authContext } = params;
try {
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
objectName,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const actorMetadata = params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
};
const cleanedRecords = objectRecords.map((record) => ({
...removeUndefinedFromRecord(record),
createdBy: actorMetadata,
}));
const createdRecords = await this.commonCreateManyRunner.execute(
{
data: cleanedRecords,
selectedFields,
},
queryRunnerContext,
);
this.logger.log(
`Created ${createdRecords.length} records in ${objectName}`,
);
return {
success: true,
message: `Created ${createdRecords.length} records in ${objectName}`,
result: params.slimResponse
? createdRecords.map((record) => ({ id: record.id }))
: createdRecords,
recordReferences: createdRecords.map((record) => ({
objectNameSingular: objectName,
recordId: record.id,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
})),
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
success: false,
message: `Failed to create records in ${objectName}`,
error: error.message,
};
}
this.logger.error(`Failed to create records: ${error}`);
return {
success: false,
message: `Failed to create records in ${objectName}`,
error:
error instanceof Error ? error.message : 'Failed to create records',
};
}
}
}
@@ -74,7 +74,7 @@ export class CreateRecordService {
return {
success: true,
message: `Record created successfully in ${objectName}`,
result: createdRecord,
result: params.slimResponse ? { id: createdRecord.id } : createdRecord,
recordReferences: [
{
objectNameSingular: objectName,
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type UpdateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/update-many-records-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@Injectable()
export class UpdateManyRecordsService {
private readonly logger = new Logger(UpdateManyRecordsService.name);
constructor(
private readonly commonUpdateManyRunner: CommonUpdateManyQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: UpdateManyRecordsParams): Promise<ToolOutput> {
const { objectName, filter, data, authContext } = params;
try {
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
objectName,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const cleanedData = removeUndefinedFromRecord(data);
const updatedRecords = await this.commonUpdateManyRunner.execute(
{ filter, data: cleanedData, selectedFields },
queryRunnerContext,
);
this.logger.log(
`Updated ${updatedRecords.length} records in ${objectName}`,
);
return {
success: true,
message: `Updated ${updatedRecords.length} records in ${objectName}`,
result: params.slimResponse
? updatedRecords.map((record) => ({ id: record.id }))
: updatedRecords,
recordReferences: updatedRecords.map((record) => ({
objectNameSingular: objectName,
recordId: record.id,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
})),
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
success: false,
message: `Failed to update records in ${objectName}`,
error: error.message,
};
}
this.logger.error(`Failed to update records: ${error}`);
return {
success: false,
message: `Failed to update records in ${objectName}`,
error:
error instanceof Error ? error.message : 'Failed to update records',
};
}
}
}
@@ -103,7 +103,7 @@ export class UpdateRecordService {
return {
success: true,
message: `Record updated successfully in ${objectName}`,
result: updatedRecord,
result: params.slimResponse ? { id: objectRecordId } : updatedRecord,
recordReferences: [
{
objectNameSingular: objectName,
@@ -1,156 +0,0 @@
import { type ToolSet } from 'ai';
import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { type UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import {
type ObjectWithPermission,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
// Dependencies required by the direct record tools factory
export type DirectRecordToolsDeps = {
createRecordService: CreateRecordService;
updateRecordService: UpdateRecordService;
deleteRecordService: DeleteRecordService;
findRecordsService: FindRecordsService;
};
export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
return (
{
objectMetadata,
restrictedFields,
canCreate,
canRead,
canUpdate,
canDelete,
}: ObjectWithPermission,
context: ToolGeneratorContext,
): ToolSet => {
const tools: ToolSet = {};
// Skip generating tools if no auth context is provided
if (!context.authContext) {
return tools;
}
// Capture authContext in a constant for use in async callbacks
const authContext = context.authContext;
if (canRead) {
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,
),
execute: async (parameters) => {
const {
loadingMessage: _,
limit,
offset,
orderBy,
...filter
} = parameters;
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter,
orderBy,
limit,
offset,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
tools[`find_one_${objectMetadata.nameSingular}`] = {
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
inputSchema: FindOneToolInputSchema,
execute: async (parameters) => {
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.id } },
limit: 1,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canCreate) {
tools[`create_${objectMetadata.nameSingular}`] = {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
inputSchema: generateCreateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { loadingMessage: _, ...objectRecord } = parameters;
return deps.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
});
},
};
}
if (canUpdate) {
tools[`update_${objectMetadata.nameSingular}`] = {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
inputSchema: generateUpdateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { loadingMessage: _, id, ...allFields } = parameters;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return deps.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canDelete) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
inputSchema: SoftDeleteToolInputSchema,
execute: async (parameters) => {
return deps.deleteRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.id,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
},
};
}
return tools;
};
};
@@ -0,0 +1,14 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CreateManyRecordsParams = {
objectName: string;
objectRecords: ObjectRecordProperties[];
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
createdBy?: ActorMetadata;
slimResponse?: boolean;
};
@@ -4,4 +4,5 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
export type RecordCrudExecutionContext = {
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
slimResponse?: boolean;
};
@@ -0,0 +1,14 @@
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type UpdateManyRecordsParams = {
objectName: string;
filter: Partial<ObjectRecordFilter>;
data: ObjectRecordProperties;
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
slimResponse?: boolean;
};
@@ -0,0 +1,26 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
export const generateCreateManyRecordInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
return z.object({
records: z
.array(recordSchema)
.min(1)
.max(20)
.describe(
'Array of records to create. Each record should contain the required fields. Maximum 20 records per call.',
),
});
};
@@ -0,0 +1,31 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
export const generateUpdateManyRecordInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const { filterSchema } = generateRecordFilterSchema(
objectMetadata,
restrictedFields,
);
const dataSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
).partial();
return z.object({
filter: filterSchema.describe(
'Filter to select which records to update. Supports field-level filters and logical operators (or, and, not). WARNING: A broad filter may update many records at once. Always verify the filter scope with a find query first.',
),
data: dataSchema.describe(
'The field values to apply to all matching records. Only include fields you want to change.',
),
});
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const DeleteToolInputSchema = z.object({
id: z.string().uuid().describe('The unique UUID of the record to delete'),
});
export type DeleteToolInput = z.infer<typeof DeleteToolInputSchema>;
@@ -1,64 +1,17 @@
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
export const generateFindToolInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const filterSchema = generateFieldFilterZodSchema(field);
if (!filterSchema) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
filterSchema;
});
// Create the base filter schema with field-level filters + logical operators
// This matches the RecordGqlOperationFilter format used by the frontend
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
const { filterShape, filterSchema } = generateRecordFilterSchema(
objectMetadata,
restrictedFields,
);
return z.object({
@@ -66,9 +19,11 @@ export const generateFindToolInputSchema = (
.number()
.int()
.positive()
.max(1000)
.default(100)
.describe('Maximum number of records to return (default: 100)'),
.max(100)
.default(10)
.describe(
'Maximum number of records to return (default: 10, max: 100). Start small and increase only if needed.',
),
offset: z
.number()
.int()
@@ -0,0 +1,67 @@
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
// Builds the per-field filter shape and full recursive filter schema
// for a given object metadata, reusable across find and updateMany tools
export const generateRecordFilterSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
): {
filterShape: Record<string, z.ZodTypeAny>;
filterSchema: z.ZodTypeAny;
} => {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const fieldFilter = generateFieldFilterZodSchema(field);
if (!fieldFilter) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
fieldFilter;
});
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
);
return { filterShape, filterSchema };
};
@@ -23,6 +23,8 @@ const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => {
case 'createdAt':
case 'updatedAt':
case 'deletedAt':
case 'createdBy':
case 'updatedBy':
return false;
default:
return true;
@@ -253,7 +255,11 @@ export const generateRecordPropertiesZodSchema = (
break;
}
if (field.description) {
if (field.name === 'position') {
fieldSchema = fieldSchema.describe(
'Leave empty to place at the top of the list (recommended).',
);
} else if (field.description) {
fieldSchema = fieldSchema.describe(field.description);
}
@@ -1,10 +0,0 @@
import { z } from 'zod';
export const SoftDeleteToolInputSchema = z.object({
id: z
.string()
.uuid()
.describe('The unique UUID of the record to soft delete'),
});
export type SoftDeleteToolInput = z.infer<typeof SoftDeleteToolInputSchema>;
@@ -0,0 +1 @@
export const COMMON_PRELOAD_TOOLS: string[] = ['search_help_center'];
@@ -4,7 +4,7 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -27,7 +27,7 @@ export type ToolProviderContext = {
// Options for tool retrieval
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: ToolType[];
excludeTools?: string[];
wrapWithErrorContext?: boolean;
};
@@ -36,5 +36,15 @@ export interface ToolProvider {
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateDescriptors(context: ToolProviderContext): Promise<ToolDescriptor[]>;
}
// NativeModelToolProvider is special: SDK-native tools are opaque and not
// serializable. It keeps the old generateTools() contract.
export interface NativeToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,153 @@
import { stripEmptyValues } from 'src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util';
describe('stripEmptyValues', () => {
it('should remove null values', () => {
expect(stripEmptyValues({ a: 1, b: null })).toEqual({ a: 1 });
});
it('should remove undefined values', () => {
expect(stripEmptyValues({ a: 1, b: undefined })).toEqual({ a: 1 });
});
it('should remove empty strings', () => {
expect(stripEmptyValues({ a: 'hello', b: '' })).toEqual({ a: 'hello' });
});
it('should remove empty objects', () => {
expect(stripEmptyValues({ a: 1, b: {} })).toEqual({ a: 1 });
});
it('should remove empty arrays', () => {
expect(stripEmptyValues({ a: 1, b: [] })).toEqual({ a: 1 });
});
it('should preserve non-empty values', () => {
expect(stripEmptyValues({ a: 0, b: false })).toEqual({ a: 0, b: false });
});
it('should recursively strip nested objects', () => {
const input = {
name: 'Acme',
address: {
city: null,
street: null,
state: null,
country: 'US',
},
links: {
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: [],
},
};
expect(stripEmptyValues(input)).toEqual({
name: 'Acme',
address: { country: 'US' },
});
});
it('should remove deeply nested empty objects', () => {
const input = {
name: 'Test',
nested: {
deep: {
empty: null,
alsoEmpty: '',
},
},
};
expect(stripEmptyValues(input)).toEqual({ name: 'Test' });
});
it('should strip empty values from arrays of objects', () => {
const input = {
records: [
{ id: '1', name: 'Acme', website: null, industry: '' },
{ id: '2', name: 'Beta', website: 'beta.com', industry: null },
],
};
expect(stripEmptyValues(input)).toEqual({
records: [
{ id: '1', name: 'Acme' },
{ id: '2', name: 'Beta', website: 'beta.com' },
],
});
});
it('should return undefined for entirely empty input', () => {
expect(stripEmptyValues({ a: null, b: '', c: {} })).toBeUndefined();
});
it('should handle a realistic tool output', () => {
const toolOutput = {
success: true,
message: 'Found 2 company records',
result: {
records: [
{
id: 'abc-123',
name: 'Acme Corp',
employees: 500,
industry: 'Technology',
website: null,
address: {
city: null,
street: null,
state: null,
country: null,
},
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
},
],
count: 1,
},
recordReferences: [
{
objectNameSingular: 'company',
recordId: 'abc-123',
displayName: 'Acme Corp',
},
],
};
expect(stripEmptyValues(toolOutput)).toEqual({
success: true,
message: 'Found 2 company records',
result: {
records: [
{
id: 'abc-123',
name: 'Acme Corp',
employees: 500,
industry: 'Technology',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
},
],
count: 1,
},
recordReferences: [
{
objectNameSingular: 'company',
recordId: 'abc-123',
displayName: 'Acme Corp',
},
],
});
});
it('should handle primitive values', () => {
expect(stripEmptyValues(42)).toBe(42);
expect(stripEmptyValues('hello')).toBe('hello');
expect(stripEmptyValues(true)).toBe(true);
expect(stripEmptyValues(false)).toBe(false);
expect(stripEmptyValues(null)).toBeUndefined();
expect(stripEmptyValues(undefined)).toBeUndefined();
expect(stripEmptyValues('')).toBeUndefined();
});
});
@@ -0,0 +1,13 @@
import { stripEmptyValues } from './strip-empty-values.util';
// Compacts a tool output by stripping empty values and flattening
// the result structure for efficient LLM token consumption.
// This is applied as a post-processing step on all tool results
// before they are returned to the AI model.
export const compactToolOutput = (output: unknown): unknown => {
if (!output || typeof output !== 'object') {
return output;
}
return stripEmptyValues(output);
};
@@ -0,0 +1,32 @@
// Recursively strips null, undefined, empty strings, empty objects,
// and empty arrays from a value. Returns undefined if the entire
// value is empty so the caller can decide whether to include it.
export const stripEmptyValues = (value: unknown): unknown => {
if (value === null || value === undefined || value === '') {
return undefined;
}
if (Array.isArray(value)) {
const cleaned = value
.map(stripEmptyValues)
.filter((item) => item !== undefined);
return cleaned.length > 0 ? cleaned : undefined;
}
if (typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const stripped = stripEmptyValues(val);
if (stripped !== undefined) {
result[key] = stripped;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
return value;
};
@@ -0,0 +1,34 @@
import { type ToolSet } from 'ai';
import { compactToolOutput } from './compact-tool-output.util';
// Wraps every tool's execute function with output serialization.
// The wrapper intercepts the raw tool result and applies compaction
// (strip nulls/empty, flatten) before the AI SDK serializes it
// into the conversation context.
//
// This is a composable utility — it can be chained with other
// wrappers like wrapToolsWithErrorContext.
export const wrapToolsWithOutputSerialization = (tools: ToolSet): ToolSet => {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
const result = await originalExecute(...args);
return compactToolOutput(result);
},
};
}
return wrappedTools;
};
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ZodObject, type ZodRawShape } from 'zod';
import { z } from 'zod';
import {
type ToolProvider,
@@ -10,47 +9,64 @@ import {
} 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 {
type StaticToolHandler,
ToolExecutorService,
} from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
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,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import {
stripLoadingMessage,
wrapSchemaForExecution,
} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
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;
private readonly toolMap: Map<string, Tool>;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly permissionsService: PermissionsService,
) {}
private readonly toolExecutorService: ToolExecutorService,
) {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
['send_email', this.sendEmailTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
]);
// Register each action tool as a static handler in the executor
for (const [toolId, tool] of this.toolMap) {
const handler: StaticToolHandler = {
execute: async (args: ToolInput, context: ToolProviderContext) =>
tool.execute(args, {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
}),
};
this.toolExecutorService.registerStaticHandler(toolId, handler);
}
}
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 executionContext: ToolExecutionContext = {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
};
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
@@ -59,10 +75,7 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasHttpPermission) {
tools['http_request'] = this.createToolEntry(
this.httpTool,
executionContext,
);
descriptors.push(this.buildDescriptor('http_request', this.httpTool));
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
@@ -72,15 +85,11 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasEmailPermission) {
tools['send_email'] = this.createToolEntry(
this.sendEmailTool,
executionContext,
);
descriptors.push(this.buildDescriptor('send_email', this.sendEmailTool));
}
tools['search_help_center'] = this.createToolEntry(
this.searchHelpCenterTool,
executionContext,
descriptors.push(
this.buildDescriptor('search_help_center', this.searchHelpCenterTool),
);
const hasCodeInterpreterPermission =
@@ -91,23 +100,21 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasCodeInterpreterPermission) {
tools['code_interpreter'] = this.createToolEntry(
this.codeInterpreterTool,
executionContext,
descriptors.push(
this.buildDescriptor('code_interpreter', this.codeInterpreterTool),
);
}
return tools;
return descriptors;
}
private createToolEntry(tool: Tool, context: ToolExecutionContext) {
private buildDescriptor(toolId: string, tool: Tool): ToolDescriptor {
return {
name: toolId,
description: tool.description,
inputSchema: wrapSchemaForExecution(
tool.inputSchema as ZodObject<ZodRawShape>,
),
execute: async (parameters: ToolInput) =>
tool.execute(stripLoadingMessage(parameters), context),
category: ToolCategory.ACTION,
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
executionRef: { kind: 'static', toolId },
};
}
}
@@ -1,6 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -10,11 +9,14 @@ import {
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service';
@Injectable()
export class DashboardToolProvider implements ToolProvider {
export class DashboardToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.DASHBOARD;
constructor(
@@ -22,8 +24,24 @@ export class DashboardToolProvider implements ToolProvider {
@Inject(DASHBOARD_TOOL_SERVICE_TOKEN)
private readonly dashboardToolService: DashboardToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
if (this.dashboardToolService) {
const service = this.dashboardToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.DASHBOARD,
async (context) =>
service.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.dashboardToolService) {
return false;
@@ -36,14 +54,18 @@ export class DashboardToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
if (!this.dashboardToolService) {
return {};
return [];
}
return this.dashboardToolService.generateDashboardTools(
const toolSet = await this.dashboardToolService.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD);
}
}
@@ -1,13 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type ToolProvider,
@@ -15,20 +13,16 @@ import {
} 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 {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.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 { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util';
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';
@@ -41,66 +35,21 @@ export class DatabaseToolProvider implements ToolProvider {
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,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
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 = {};
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
// Both userId and userWorkspaceId are required for user-based tool generation
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
return tools;
return descriptors;
}
const user = await this.userRepository.findOne({
where: {
id: context.userId,
},
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
const workspaceMember = isDefined(workspaceMemberId)
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
: undefined;
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
throw new AuthException(
'Workspace member not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const authContext: WorkspaceAuthContext = buildUserAuthContext({
workspace: { id: context.workspaceId } as WorkspaceEntity,
userWorkspaceId: context.userWorkspaceId,
user,
workspaceMemberId,
workspaceMember,
});
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'rolesPermissions',
@@ -112,7 +61,7 @@ export class DatabaseToolProvider implements ToolProvider {
);
if (!objectPermissions) {
return tools;
return descriptors;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
@@ -127,17 +76,13 @@ export class DatabaseToolProvider implements ToolProvider {
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
.filter((obj) => obj.isActive);
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
if (
isWorkflowRelatedObject(flatObject) ||
isFavoriteRelatedObject(flatObject)
) {
continue;
}
@@ -155,27 +100,132 @@ export class DatabaseToolProvider implements ToolProvider {
),
};
const objectTools = factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId: context.workspaceId,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
actorContext: context.actorContext,
},
);
const restrictedFields = permission.restrictedFields;
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
Object.assign(tools, objectTools);
if (permission.canReadObjectRecords) {
descriptors.push({
name: `find_${snakePlural}`,
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.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'find',
},
objectName: objectMetadata.nameSingular,
operation: 'find',
});
descriptors.push({
name: `find_one_${snakeSingular}`,
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'find_one',
},
objectName: objectMetadata.nameSingular,
operation: 'find_one',
});
}
if (permission.canUpdateObjectRecords) {
descriptors.push({
name: `create_${snakeSingular}`,
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'create',
},
objectName: objectMetadata.nameSingular,
operation: 'create',
});
descriptors.push({
name: `create_many_${snakePlural}`,
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'create_many',
},
objectName: objectMetadata.nameSingular,
operation: 'create_many',
});
descriptors.push({
name: `update_${snakeSingular}`,
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'update',
},
objectName: objectMetadata.nameSingular,
operation: 'update',
});
descriptors.push({
name: `update_many_${snakePlural}`,
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'update_many',
},
objectName: objectMetadata.nameSingular,
operation: 'update_many',
});
}
if (permission.canSoftDeleteObjectRecords) {
descriptors.push({
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'delete',
},
objectName: objectMetadata.nameSingular,
operation: 'delete',
});
}
}
return tools;
return descriptors;
}
private getObjectPermissions(
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { jsonSchema, type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -9,26 +8,25 @@ import {
} 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 { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
@Injectable()
export class LogicFunctionToolProvider implements ToolProvider {
readonly category = ToolCategory.LOGIC_FUNCTION;
constructor(
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Logic function tools are available if there are any functions marked as tools
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -37,7 +35,6 @@ export class LogicFunctionToolProvider implements ToolProvider {
},
);
// Filter logic functions that are marked as tools
const logicFunctionsWithSchema = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
@@ -45,49 +42,35 @@ export class LogicFunctionToolProvider implements ToolProvider {
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const tools: ToolSet = {};
const descriptors: ToolDescriptor[] = [];
for (const logicFunction of logicFunctionsWithSchema) {
const toolName = this.buildLogicFunctionToolName(logicFunction.name);
const wrappedSchema = wrapJsonSchemaForExecution(
logicFunction.toolInputSchema as Record<string, unknown>,
);
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
tools[toolName] = {
descriptors.push({
name: toolName,
description:
logicFunction.description ||
`Execute the ${logicFunction.name} logic function`,
inputSchema: jsonSchema(wrappedSchema),
execute: async (parameters: Record<string, unknown>) => {
const { loadingMessage: _, ...actualParams } = parameters;
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: logicFunction.id,
workspaceId: context.workspaceId,
payload: actualParams,
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
category: ToolCategory.LOGIC_FUNCTION,
inputSchema,
executionRef: {
kind: 'logic_function',
logicFunctionId: logicFunction.id,
},
};
});
}
return tools;
return descriptors;
}
private buildLogicFunctionToolName(functionName: string): string {
// Convert function name to a valid tool name (lowercase, underscores)
return `logic_function_${functionName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -9,20 +8,37 @@ import {
} 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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
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 {
export class MetadataToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.METADATA;
constructor(
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const objectFactory = this.objectMetadataToolsFactory;
const fieldFactory = this.fieldMetadataToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.METADATA,
async (context) => ({
...objectFactory.generateTools(context.workspaceId),
...fieldFactory.generateTools(context.workspaceId),
}),
);
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
@@ -31,10 +47,14 @@ export class MetadataToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
return {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const toolSet = {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
return toolSetToDescriptors(toolSet, ToolCategory.METADATA);
}
}
@@ -4,7 +4,7 @@ import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type NativeToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -12,8 +12,10 @@ import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-c
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
// SDK-native tools (anthropic webSearch, etc.) are opaque and not serializable.
// This provider keeps generateTools() and is excluded from the descriptor system.
@Injectable()
export class NativeModelToolProvider implements ToolProvider {
export class NativeModelToolProvider implements NativeToolProvider {
readonly category = ToolCategory.NATIVE_MODEL;
constructor(
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -9,23 +8,64 @@ import {
} 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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
export class ViewToolProvider implements ToolProvider {
export class ViewToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.VIEW;
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW,
async (context) => {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = factory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
return { ...readTools, ...writeTools };
}
return readTools;
},
);
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = this.viewToolsFactory.generateReadTools(
@@ -47,9 +87,12 @@ export class ViewToolProvider implements ToolProvider {
workspaceMemberId ?? undefined,
);
return { ...readTools, ...writeTools };
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW,
);
}
return readTools;
return toolSetToDescriptors(readTools, ToolCategory.VIEW);
}
}
@@ -1,6 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -10,11 +9,14 @@ import {
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 { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
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 {
export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.WORKFLOW;
constructor(
@@ -22,8 +24,24 @@ export class WorkflowToolProvider implements ToolProvider {
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
if (this.workflowToolService) {
const service = this.workflowToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.WORKFLOW,
async (context) =>
service.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.workflowToolService) {
return false;
@@ -36,14 +54,18 @@ export class WorkflowToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
if (!this.workflowToolService) {
return {};
return [];
}
return this.workflowToolService.generateWorkflowTools(
const toolSet = await this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW);
}
}
@@ -0,0 +1,310 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
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 { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
// Handler for individually registered static tools (e.g., action tools)
export interface StaticToolHandler {
execute(args: ToolInput, context: ToolProviderContext): Promise<unknown>;
}
// Generator that produces a ToolSet on demand for a category (workflow, view, etc.)
// Used as a fallback when no per-tool handler is registered.
export type CategoryToolGenerator = (
context: ToolProviderContext,
) => Promise<ToolSet>;
@Injectable()
export class ToolExecutorService {
private readonly logger = new Logger(ToolExecutorService.name);
// Per-tool handlers (action tools, etc.)
private readonly staticToolHandlers = new Map<string, StaticToolHandler>();
// Category-level ToolSet generators (workflow, view, dashboard, metadata)
private readonly categoryGenerators = new Map<
ToolCategory,
CategoryToolGenerator
>();
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly createRecordService: CreateRecordService,
private readonly createManyRecordsService: CreateManyRecordsService,
private readonly updateRecordService: UpdateRecordService,
private readonly updateManyRecordsService: UpdateManyRecordsService,
private readonly deleteRecordService: DeleteRecordService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
registerStaticHandler(toolId: string, handler: StaticToolHandler): void {
this.staticToolHandlers.set(toolId, handler);
}
registerCategoryGenerator(
category: ToolCategory,
generator: CategoryToolGenerator,
): void {
this.categoryGenerators.set(category, generator);
}
async dispatch(
descriptor: ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const cleanArgs = stripLoadingMessage(args);
switch (descriptor.executionRef.kind) {
case 'database_crud':
return this.dispatchDatabaseCrud(
descriptor.executionRef,
cleanArgs,
context,
);
case 'static':
return this.dispatchStaticTool(descriptor, cleanArgs, context);
case 'logic_function':
return this.dispatchLogicFunction(
descriptor.executionRef,
cleanArgs,
context,
);
}
}
private async dispatchDatabaseCrud(
ref: { objectNameSingular: string; operation: string },
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const authContext =
context.authContext ?? (await this.buildAuthContext(context));
switch (ref.operation) {
case 'find': {
const { limit, offset, orderBy, ...filter } = args;
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter,
orderBy: orderBy as never,
limit: limit as number | undefined,
offset: offset as number | undefined,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
}
case 'find_one':
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter: { id: { eq: args.id } },
limit: 1,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
case 'create':
return this.createRecordService.execute({
objectName: ref.objectNameSingular,
objectRecord: args,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
slimResponse: true,
});
case 'create_many':
return this.createManyRecordsService.execute({
objectName: ref.objectNameSingular,
objectRecords: args.records as Record<string, unknown>[],
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
slimResponse: true,
});
case 'update': {
const { id, ...fields } = args;
const objectRecord = Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
return this.updateRecordService.execute({
objectName: ref.objectNameSingular,
objectRecordId: id as string,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
slimResponse: true,
});
}
case 'update_many':
return this.updateManyRecordsService.execute({
objectName: ref.objectNameSingular,
filter: args.filter as Record<string, unknown>,
data: args.data as Record<string, unknown>,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
slimResponse: true,
});
case 'delete':
return this.deleteRecordService.execute({
objectName: ref.objectNameSingular,
objectRecordId: args.id as string,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
default:
throw new Error(`Unknown database_crud operation: ${ref.operation}`);
}
}
private async dispatchStaticTool(
descriptor: ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
if (descriptor.executionRef.kind !== 'static') {
throw new Error('Expected static executionRef');
}
// Per-tool handler first (action tools)
const handler = this.staticToolHandlers.get(descriptor.executionRef.toolId);
if (handler) {
return handler.execute(args, context);
}
// Category-level generator fallback (workflow, view, dashboard, metadata)
const generator = this.categoryGenerators.get(descriptor.category);
if (!generator) {
throw new Error(
`No handler or generator for static tool: ${descriptor.executionRef.toolId}`,
);
}
const toolSet = await generator(context);
const tool = toolSet[descriptor.name];
if (!tool?.execute) {
throw new Error(
`Tool ${descriptor.name} not found in generated ToolSet for category ${descriptor.category}`,
);
}
// The tool's execute expects (args, ToolCallOptions). Pass args with
// a dummy loadingMessage since the tool's internal strip is harmless.
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
);
}
private async dispatchLogicFunction(
ref: { logicFunctionId: string },
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: ref.logicFunctionId,
workspaceId: context.workspaceId,
payload: args,
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
}
// Build authContext on demand for database CRUD operations
private async buildAuthContext(
context: ToolProviderContext,
): Promise<WorkspaceAuthContext> {
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
throw new AuthException(
'userId and userWorkspaceId are required for database operations',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const user = await this.userRepository.findOne({
where: { id: context.userId },
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
const workspaceMember = isDefined(workspaceMemberId)
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
: undefined;
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
throw new AuthException(
'Workspace member not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
return buildUserAuthContext({
workspace: { id: context.workspaceId } as WorkspaceEntity,
userWorkspaceId: context.userWorkspaceId,
user,
workspaceMemberId,
workspaceMember,
});
}
}
@@ -1,46 +1,34 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet, zodSchema } from 'ai';
import { type ToolCallOptions, type ToolSet, jsonSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ZodType } from 'zod';
import {
type CodeExecutionStreamEmitter,
type NativeToolProvider,
type ToolProvider,
type ToolProviderContext,
type ToolRetrievalOptions,
} 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 { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
export type ToolIndexEntry = {
name: string;
description: string;
category:
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'LOGIC_FUNCTION';
objectName?: string;
operation?: string;
inputSchema?: object;
};
// Backward-compatible alias -- consumers can import this instead of ToolDescriptor
export type ToolIndexEntry = ToolDescriptor;
export type ToolSearchOptions = {
limit?: number;
category?:
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'LOGIC_FUNCTION';
category?: ToolCategory;
};
export type ToolContext = {
@@ -52,20 +40,119 @@ export type ToolContext = {
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
const RAM_TTL_MS = 5_000;
const REDIS_TTL_MS = 300_000;
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
// Two-tier cache: RAM (5s) → Redis (5min) → generate from providers
private readonly ramCache = new Map<
string,
{ descriptors: ToolDescriptor[]; cachedAt: number }
>();
constructor(
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
private readonly nativeModelToolProvider: NativeModelToolProvider,
private readonly toolExecutorService: ToolExecutorService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
// Core: returns cached ToolDescriptor[] for a workspace+role+user
async getCatalog(context: ToolProviderContext): Promise<ToolDescriptor[]> {
const cacheKey = await this.buildCacheKey(context);
// 1. RAM hit?
const ramEntry = this.ramCache.get(cacheKey);
if (ramEntry && Date.now() - ramEntry.cachedAt < RAM_TTL_MS) {
return ramEntry.descriptors;
}
// 2. Redis hit?
const redisData =
await this.workspaceCacheStorageService.getToolCatalog(cacheKey);
if (redisData) {
const descriptors = redisData as ToolDescriptor[];
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
return descriptors;
}
// 3. Generate from providers (cache miss)
const descriptors: ToolDescriptor[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const providerDescriptors = await provider.generateDescriptors(context);
descriptors.push(...providerDescriptors);
}
}
this.logger.log(
`Generated ${descriptors.length} tool descriptors for workspace ${context.workspaceId}`,
);
// Store in both caches
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
await this.workspaceCacheStorageService.setToolCatalog(
cacheKey,
descriptors,
REDIS_TTL_MS,
);
return descriptors;
}
// Hydrate ToolDescriptor[] into an AI SDK ToolSet with thin dispatch closures
hydrateToolSet(
descriptors: ToolDescriptor[],
context: ToolProviderContext,
options?: { wrapWithErrorContext?: boolean },
): ToolSet {
const toolSet: ToolSet = {};
for (const descriptor of descriptors) {
// Add loadingMessage to the clean stored schema
const schemaWithLoading = wrapJsonSchemaForExecution(
descriptor.inputSchema as Record<string, unknown>,
);
const executeFn = async (
args: Record<string, unknown>,
): Promise<unknown> =>
this.toolExecutorService.dispatch(descriptor, args, context);
toolSet[descriptor.name] = {
description: descriptor.description,
inputSchema: jsonSchema(schemaWithLoading),
execute: options?.wrapWithErrorContext
? this.wrapWithErrorHandler(descriptor.name, executeFn)
: executeFn,
};
}
return toolSet;
}
async buildToolIndex(
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolIndexEntry[]> {
): Promise<ToolDescriptor[]> {
const context = this.buildContext(
workspaceId,
roleId,
@@ -73,21 +160,8 @@ export class ToolRegistryService {
options?.userId,
options?.userWorkspaceId,
);
const entries: ToolIndexEntry[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const tools = await provider.generateTools(context);
entries.push(...this.toolSetToIndex(tools, provider.category));
}
}
this.logger.log(
`Built tool index with ${entries.length} tools for workspace ${workspaceId}`,
);
return entries;
return this.getCatalog(context);
}
async searchTools(
@@ -98,19 +172,24 @@ export class ToolRegistryService {
userId?: string;
userWorkspaceId?: string;
} = {},
): Promise<ToolIndexEntry[]> {
): Promise<ToolDescriptor[]> {
const { limit = 5, category, userId, userWorkspaceId } = options;
const index = await this.buildToolIndex(workspaceId, roleId, {
const context = this.buildContext(
workspaceId,
roleId,
undefined,
userId,
userWorkspaceId,
});
);
const descriptors = await this.getCatalog(context);
const queryLower = query.toLowerCase();
const queryTerms = queryLower
.split(/\s+/)
.filter((term) => term.length > 2);
const scored = index
const scored = descriptors
.filter((tool) => !category || tool.category === category)
.map((tool) => {
let score = 0;
@@ -171,59 +250,168 @@ export class ToolRegistryService {
context.userId,
context.userWorkspaceId,
);
const allTools: ToolSet = {};
for (const provider of this.providers) {
if (await provider.isAvailable(fullContext)) {
const tools = await provider.generateTools(fullContext);
Object.assign(allTools, tools);
}
}
return Object.fromEntries(
names
.filter((name) => name in allTools)
.map((name) => [name, allTools[name]]),
const descriptors = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((descriptor) =>
nameSet.has(descriptor.name),
);
return this.hydrateToolSet(filtered, fullContext);
}
// Main method for eager loading tools by categories (replaces ToolProviderService.getTools)
async getToolInfo(
names: string[],
context: ToolContext,
aspects: LearnToolsAspect[] = ['description', 'schema'],
): Promise<
Array<{ name: string; description?: string; inputSchema?: object }>
> {
const fullContext = this.buildContext(
context.workspaceId,
context.roleId,
context.onCodeExecutionUpdate,
context.userId,
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((entry) => nameSet.has(entry.name));
return filtered.map((entry) => {
const info: {
name: string;
description?: string;
inputSchema?: object;
} = { name: entry.name };
if (aspects.includes('description')) {
info.description = entry.description;
}
if (aspects.includes('schema')) {
info.inputSchema = entry.inputSchema;
}
return info;
});
}
async resolveAndExecute(
toolName: string,
args: Record<string, unknown>,
context: ToolContext,
_options: ToolCallOptions,
): Promise<ExecuteToolResult> {
try {
const fullContext = this.buildContext(
context.workspaceId,
context.roleId,
context.onCodeExecutionUpdate,
context.userId,
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const descriptor = descriptors.find((desc) => desc.name === toolName);
if (!descriptor) {
return {
toolName,
error: {
message: `Tool "${toolName}" not found. Check the tool catalog for correct names.`,
suggestion:
'Use learn_tools to discover available tools and their correct names.',
},
};
}
const result = await this.toolExecutorService.dispatch(
descriptor,
args,
fullContext,
);
return {
toolName,
result: compactToolOutput(result),
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.error(`Error executing tool "${toolName}": ${errorMessage}`);
return {
toolName,
error: {
message: errorMessage,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
}
// Main method for eager loading tools by categories
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const tools: ToolSet = {};
const descriptors = await this.getCatalog(context);
for (const provider of this.providers) {
if (categories && !categories.includes(provider.category)) {
continue;
}
if (await provider.isAvailable(context)) {
const providerTools = await provider.generateTools(context);
let filteredDescriptors: ToolDescriptor[];
Object.assign(tools, providerTools);
}
if (categories) {
const categorySet = new Set(categories);
filteredDescriptors = descriptors.filter((descriptor) =>
categorySet.has(descriptor.category),
);
} else {
filteredDescriptors = [...descriptors];
}
// Apply excludeTools filter
if (excludeTools?.length) {
for (const toolType of excludeTools) {
delete tools[toolType.toLowerCase()];
const excludeSet = new Set(excludeTools);
filteredDescriptors = filteredDescriptors.filter(
(descriptor) => !excludeSet.has(descriptor.name),
);
}
const toolSet = this.hydrateToolSet(filteredDescriptors, context, {
wrapWithErrorContext,
});
// Handle NativeModelToolProvider separately (SDK-opaque tools)
if (categories?.includes(ToolCategory.NATIVE_MODEL)) {
if (await this.nativeModelToolProvider.isAvailable(context)) {
const nativeTools = await (
this.nativeModelToolProvider as NativeToolProvider
).generateTools(context);
Object.assign(toolSet, nativeTools);
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
`Generated ${Object.keys(toolSet).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
);
// Apply error wrapping if requested
if (wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return toolSet;
}
return tools;
private async buildCacheKey(context: ToolProviderContext): Promise<string> {
const metadataVersion =
(await this.workspaceCacheStorageService.getMetadataVersion(
context.workspaceId,
)) ?? 0;
return `${context.workspaceId}:v${metadataVersion}:${context.roleId}:${context.userId ?? 'system'}`;
}
private buildContext(
@@ -247,143 +435,27 @@ export class ToolRegistryService {
};
}
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',
VIEW: 'VIEW',
DASHBOARD: 'DASHBOARD',
LOGIC_FUNCTION: 'LOGIC_FUNCTION',
};
return Object.entries(tools).map(([name, tool]) => {
const inputSchema = this.extractJsonSchema(tool.inputSchema);
return {
name,
description: tool.description ?? '',
category: categoryMap[category],
inputSchema,
};
});
}
private extractJsonSchema(inputSchema: unknown): object | undefined {
if (!inputSchema) {
return undefined;
}
let schema: object | undefined;
// Check if it's a Zod schema (has _def property)
if (
typeof inputSchema === 'object' &&
inputSchema !== null &&
'_def' in inputSchema
) {
private wrapWithErrorHandler(
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): (args: Record<string, unknown>) => Promise<unknown> {
return async (args: Record<string, unknown>) => {
try {
// Use AI SDK's zodSchema() to convert Zod to JSON Schema
const converted = zodSchema(inputSchema as ZodType);
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
schema = converted.jsonSchema as object;
} catch {
// If conversion fails, return undefined
return undefined;
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
} else if (
// Check if AI SDK wrapped it with jsonSchema property
typeof inputSchema === 'object' &&
inputSchema !== null &&
'jsonSchema' in inputSchema
) {
schema = (inputSchema as { jsonSchema: object }).jsonSchema;
} else if (typeof inputSchema === 'object') {
// Return as-is if it's already an object (plain JSON schema)
schema = inputSchema as object;
}
if (!schema) {
return undefined;
}
return this.stripInternalFieldsFromSchema(schema);
}
// Remove internal fields (loadingMessage) from schema for display
private stripInternalFieldsFromSchema(schema: object): object {
const schemaObj = schema as Record<string, unknown>;
// Remove $schema property
const { $schema: _, ...rest } = schemaObj;
// Remove loadingMessage from properties if present
// loadingMessage is an internal field auto-injected for AI status updates
if (
rest.type === 'object' &&
rest.properties &&
typeof rest.properties === 'object'
) {
const properties = rest.properties as Record<string, unknown>;
const { loadingMessage: __, ...cleanProperties } = properties;
// Filter required array to remove loadingMessage if present
const required = Array.isArray(rest.required)
? rest.required.filter((field) => field !== 'loadingMessage')
: undefined;
return {
...rest,
properties: cleanProperties,
...(required && required.length > 0 ? { required } : {}),
};
}
return rest;
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
};
}
private generateErrorSuggestion(
@@ -12,6 +12,7 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
@@ -24,6 +25,7 @@ import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
@@ -45,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
PermissionsModule,
ViewModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
UserRoleModule,
@@ -52,6 +55,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
],
providers: [
ToolIndexResolver,
ToolExecutorService,
ActionToolProvider,
DashboardToolProvider,
DatabaseToolProvider,
@@ -61,13 +65,14 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewToolProvider,
WorkflowToolProvider,
{
// TOOL_PROVIDERS contains only providers implementing ToolProvider (generateDescriptors).
// NativeModelToolProvider is excluded -- it's injected separately in the registry.
provide: TOOL_PROVIDERS,
useFactory: (
actionProvider: ActionToolProvider,
dashboardProvider: DashboardToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
nativeModelProvider: NativeModelToolProvider,
logicFunctionProvider: LogicFunctionToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
@@ -76,7 +81,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider,
databaseProvider,
metadataProvider,
nativeModelProvider,
logicFunctionProvider,
viewProvider,
workflowProvider,
@@ -86,7 +90,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
LogicFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
@@ -0,0 +1,57 @@
import { type ToolCallOptions, type ToolSet } from 'ai';
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
export const executeToolInputSchema = z.object({
toolName: z.string().describe('Exact name of the tool to execute.'),
arguments: z
.record(z.string(), z.unknown())
.describe(
'Arguments to pass to the tool. Must match the schema from learn_tools.',
),
});
export type ExecuteToolInput = z.infer<typeof executeToolInputSchema>;
export type ExecuteToolResult = {
toolName: string;
result?: unknown;
error?: {
message: string;
suggestion: string;
};
};
export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
directTools?: ToolSet,
) => ({
description:
'Execute a tool by name. Use learn_tools first to discover the correct schema, then call this with the tool name and arguments.',
inputSchema: executeToolInputSchema,
execute: async (
parameters: ExecuteToolInput,
options: ToolCallOptions,
): Promise<ExecuteToolResult> => {
const { toolName, arguments: args } = parameters;
// Native provider tools and preloaded tools are already in the ToolSet;
// dispatch directly if the LLM routes them through execute_tool.
const directTool = directTools?.[toolName];
if (directTool?.execute) {
const result = await directTool.execute(args, options);
return { toolName, result };
}
return toolRegistry.resolveAndExecute(toolName, args, context, options);
},
});
@@ -1,11 +1,19 @@
export {
LOAD_TOOLS_TOOL_NAME,
createLoadToolsTool,
loadToolsInputSchema,
type DynamicToolStore,
type LoadToolsInput,
type LoadToolsResult,
} from './load-tools.tool';
LEARN_TOOLS_TOOL_NAME,
createLearnToolsTool,
learnToolsInputSchema,
type LearnToolsAspect,
type LearnToolsInput,
type LearnToolsResult,
} from './learn-tools.tool';
export {
EXECUTE_TOOL_TOOL_NAME,
createExecuteToolTool,
executeToolInputSchema,
type ExecuteToolInput,
type ExecuteToolResult,
} from './execute-tool.tool';
export {
LOAD_SKILL_TOOL_NAME,
@@ -0,0 +1,74 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
const learnToolsAspectSchema = z.enum(['description', 'schema']);
export type LearnToolsAspect = z.infer<typeof learnToolsAspectSchema>;
export const learnToolsInputSchema = z.object({
toolNames: z
.array(z.string())
.describe(
'Tool names to learn about. Use exact names from the tool catalog.',
),
aspects: z
.array(learnToolsAspectSchema)
.optional()
.default(['description', 'schema'])
.describe('What to learn: description, schema, or both.'),
});
export type LearnToolsInput = z.infer<typeof learnToolsInputSchema>;
export type LearnToolsResultEntry = {
name: string;
description?: string;
inputSchema?: object;
};
export type LearnToolsResult = {
tools: LearnToolsResultEntry[];
notFound: string[];
message: string;
};
export const createLearnToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
) => ({
description:
'Learn about tools before using them. Returns tool descriptions and/or input schemas so you know how to call them via execute_tool.',
inputSchema: learnToolsInputSchema,
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
const { toolNames, aspects } = parameters;
const toolInfos = await toolRegistry.getToolInfo(
toolNames,
context,
aspects,
);
const foundNames = new Set(toolInfos.map((t) => t.name));
const notFound = toolNames.filter((name) => !foundNames.has(name));
if (notFound.length > 0) {
return {
tools: toolInfos,
notFound,
message: `Learned ${toolInfos.length} tool(s). Could not find: ${notFound.join(', ')}.`,
};
}
return {
tools: toolInfos,
notFound: [],
message: `Learned ${toolInfos.length} tool(s): ${toolInfos.map((t) => t.name).join(', ')}.`,
};
},
});
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
export const LOAD_SKILL_TOOL_NAME = 'load_skills';
export const loadSkillInputSchema = z.object({
skillNames: z
@@ -1,73 +0,0 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const LOAD_TOOLS_TOOL_NAME = 'load_tools' as const;
export const loadToolsInputSchema = z.object({
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
});
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>;
export type LoadToolsResult = {
loaded: string[];
notFound: string[];
message: string;
};
export type DynamicToolStore = {
loadedTools: Set<string>;
};
export const createLoadToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
dynamicToolStore: DynamicToolStore,
onToolsLoaded: (toolNames: string[]) => Promise<void>,
) => ({
description: `Load tools by name to make them available for use. Call this when you need to use a tool from the catalog that isn't already loaded. You can load multiple tools at once.`,
inputSchema: loadToolsInputSchema,
execute: async (parameters: LoadToolsInput): Promise<LoadToolsResult> => {
const { toolNames } = parameters;
const loaded: string[] = [];
const notFound: string[] = [];
const tools = await toolRegistry.getToolsByName(toolNames, context);
for (const name of toolNames) {
if (tools[name]) {
loaded.push(name);
dynamicToolStore.loadedTools.add(name);
} else {
notFound.push(name);
}
}
if (loaded.length > 0) {
await onToolsLoaded(loaded);
}
if (notFound.length > 0) {
return {
loaded,
notFound,
message: `Loaded ${loaded.length} tool(s). Could not find: ${notFound.join(', ')}. Check the tool catalog for correct names.`,
};
}
return {
loaded,
notFound: [],
message: `Successfully loaded ${loaded.length} tool(s): ${loaded.join(', ')}. These tools are now available for use.`,
};
},
});
@@ -0,0 +1,30 @@
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
export type DatabaseCrudOperation =
| 'find'
| 'find_one'
| 'create'
| 'create_many'
| 'update'
| 'update_many'
| 'delete';
export type ToolExecutionRef =
| {
kind: 'database_crud';
objectNameSingular: string;
operation: DatabaseCrudOperation;
}
| { kind: 'static'; toolId: string }
| { kind: 'logic_function'; logicFunctionId: string };
// Fully JSON-serializable tool definition, stored in Redis
export type ToolDescriptor = {
name: string;
description: string;
category: ToolCategory;
inputSchema: object;
executionRef: ToolExecutionRef;
objectName?: string;
operation?: string;
};
@@ -0,0 +1,32 @@
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
// Converts a ToolSet (with Zod schemas and closures) into an array of
// serializable ToolDescriptor objects. Used by providers that delegate to
// existing factory services (workflow, view, dashboard, metadata).
export const toolSetToDescriptors = (
toolSet: ToolSet,
category: ToolCategory,
): ToolDescriptor[] => {
return Object.entries(toolSet).map(([name, tool]) => {
let inputSchema: object;
try {
inputSchema = z.toJSONSchema(tool.inputSchema as z.ZodType);
} catch {
// Fallback: schema is already JSON Schema or another format
inputSchema = (tool.inputSchema ?? {}) as object;
}
return {
name,
description: tool.description ?? '',
category,
inputSchema,
executionRef: { kind: 'static' as const, toolId: name },
};
});
};
@@ -1262,6 +1262,15 @@ export class ConfigVariables {
@IsOptional()
XAI_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Groq integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
GROQ_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Enable or disable multi-workspace support',
@@ -117,6 +117,11 @@ export class UpdateWorkspaceInput {
@IsOptional()
smartModel?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
aiAdditionalInstructions?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@@ -84,6 +84,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
defaultRoleId: PermissionFlagType.ROLES,
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
aiAdditionalInstructions: PermissionFlagType.WORKSPACE,
};
constructor(
@@ -296,6 +296,10 @@ export class WorkspaceEntity {
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
smartModel: ModelId;
@Field(() => String, { nullable: true })
@Column({ type: 'text', nullable: true })
aiAdditionalInstructions: string | null;
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;
@@ -12,11 +12,19 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
export type UserContext = {
firstName: string;
lastName: string;
locale: string;
timezone: string | null;
};
export type AgentActorContext = {
actorContext: ActorMetadata;
roleId: string;
userId: string;
userWorkspaceId: string;
userContext: UserContext;
};
@Injectable()
@@ -87,11 +95,19 @@ export class AgentActorContextService {
workspaceMemberId: workspaceMember.id,
});
const userContext: UserContext = {
firstName: workspaceMember.name?.firstName ?? '',
lastName: workspaceMember.name?.lastName ?? '',
locale: userWorkspace.locale,
timezone: workspaceMember.timeZone ?? null,
};
return {
actorContext,
roleId,
userId: userWorkspace.userId,
userWorkspaceId,
userContext,
};
}
}
@@ -0,0 +1,14 @@
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
const FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS = [
STANDARD_OBJECTS.favorite.universalIdentifier,
STANDARD_OBJECTS.favoriteFolder.universalIdentifier,
] as const;
export const isFavoriteRelatedObject = (objectMetadata: {
universalIdentifier: string;
}): boolean => {
return FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.includes(
objectMetadata.universalIdentifier as (typeof FAVORITE_STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS)[number],
);
};
@@ -29,6 +29,7 @@ import { AgentChatStreamingService } from './services/agent-chat-streaming.servi
import { AgentChatService } from './services/agent-chat.service';
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
import { ChatExecutionService } from './services/chat-execution.service';
import { SystemPromptBuilderService } from './services/system-prompt-builder.service';
@Module({
imports: [
@@ -63,6 +64,7 @@ import { ChatExecutionService } from './services/chat-execution.service';
AgentChatStreamingService,
AgentTitleGenerationService,
ChatExecutionService,
SystemPromptBuilderService,
],
exports: [
AgentChatService,
@@ -1,47 +1,54 @@
// System prompts for AI Chat (user-facing conversational interface)
export const CHAT_SYSTEM_PROMPTS = {
// Core chat behavior and tool strategy
BASE: `You are a helpful AI assistant integrated into Twenty CRM.
BASE: `You are a helpful AI assistant integrated into Twenty, a CRM (similar to Salesforce).
Tool usage strategy:
- Chain multiple tools to solve complex tasks
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent
- Validate assumptions before making changes
## Plan → Skill → Learn → Execute
For ANY non-trivial task, follow this order:
1. **Plan**: Identify what the user needs. Determine which domain is involved (workflows, dashboards, metadata, data, documents, etc.).
2. **Load the relevant skill FIRST**: Call \`load_skills\` to get detailed instructions, correct schemas, and parameter formats BEFORE doing anything else. Skills contain critical knowledge you don't have built-in — skipping this step leads to incorrect parameters and failed tool calls.
3. **Learn the required tools**: Call \`learn_tools\` to discover tool schemas and descriptions before using them.
4. **Execute**: Call \`execute_tool\` to run the tools following the instructions from the skill.
⚠️ NEVER call a specialized tool (workflow, dashboard, metadata, etc.) without loading its matching skill first. The Available Skills section below lists all skills — look for the one that matches the user's task domain and load it.
Examples:
- User asks to create a workflow → \`load_skills(["workflow-building"])\` then learn and execute workflow tools
- User asks to build a dashboard → \`load_skills(["dashboard-building"])\` then learn and execute dashboard tools
- User asks to export data to Excel → \`load_skills(["xlsx", "code-interpreter"])\` then \`learn_tools({toolNames: ["code_interpreter"]})\` then \`execute_tool({toolName: "code_interpreter", arguments: {...}})\`
For simple CRUD operations (find/create/update/delete a record), you do NOT need a skill — but you still MUST call \`learn_tools\` first to learn the tool schema, then \`execute_tool\` to run it.
## Skills vs Tools
- **SKILLS** = documentation/instructions (loaded via \`load_skills\`). They teach you HOW to do something — correct schemas, parameters, and patterns. They do NOT give you execution ability.
- **TOOLS** = execution capabilities via \`execute_tool\`. They let you DO something. Use \`learn_tools\` to discover the correct parameters first.
- You need BOTH: skill for knowledge, \`execute_tool\` for action.
## Database vs HTTP Tools
Database vs HTTP tools:
- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations
- NEVER guess or construct API URLs - always use the appropriate database tool
- NEVER guess or construct API URLs always use the appropriate database tool
- The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data)
- If you need to look up a record, load and use the corresponding find_one_* or find_many_* tool
- If you need to look up a record, learn and execute the corresponding find_one_* or find_many_* tool
Error recovery:
- Analyze error messages to understand what went wrong
- Adjust parameters or try different tools
- Only give up after exhausting reasonable alternatives
## Data Efficiency
Permissions:
- Only perform actions your role allows
- Explain limitations if you lack permissions
- Use small limits (5-10 records) for initial exploration. Only increase if the user explicitly needs more.
- Always apply filters to narrow results — don't fetch all records of a type.
- Fetch one type of data at a time and check if you have what you need before fetching more.
- Every record returned consumes context. Fetching too many records at once will cause failures.
Skills vs Tools:
- SKILLS = documentation/instructions (loaded via \`load_skill\`). They teach you HOW to do something.
- TOOLS = execution capabilities (loaded via \`load_tools\`). They let you DO something.
- Skills don't give you abilities - they give you knowledge. You still need the tool to act.
## Tool Strategy
Python Code Execution:
- To run Python code, you need TWO things:
1. Load the skill for instructions: \`load_skill(["code-interpreter"])\`
2. Load the tool for execution: \`load_tools(["code_interpreter"])\`
- Then call \`code_interpreter\` with your Python code
- The Python environment includes a \`twenty\` helper to call any Twenty tool directly from code
Document Processing (Excel, PDF, Word, PowerPoint):
- For document tasks, load both the skill AND the code_interpreter tool:
1. \`load_skill(["xlsx"])\` or \`load_skill(["pdf"])\` etc. - gets you detailed instructions
2. \`load_tools(["code_interpreter"])\` - enables code execution
- Then use \`code_interpreter\` to run the Python code described in the skill`,
- Chain multiple tools to solve complex tasks
- Use results from one tool to inform the next
- If a tool fails, analyze the error, adjust parameters, and try again
- Don't give up after first failure — be persistent and try alternative approaches
- Validate assumptions before making changes
`,
// Response formatting and record references
RESPONSE_FORMAT: `
@@ -1,4 +1,4 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { Field, Float, Int, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -20,9 +20,14 @@ export class AgentChatThreadDTO {
contextWindowTokens: number | null;
@Field(() => Int)
conversationSize: number;
// Credits are converted from internal precision to display precision
// (internal / 1000) at the resolver level
@Field(() => Float)
totalInputCredits: number;
@Field(() => Int)
@Field(() => Float)
totalOutputCredits: number;
@Field()
@@ -0,0 +1,22 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('AISystemPromptSection')
export class AISystemPromptSectionDTO {
@Field(() => String)
title: string;
@Field(() => String)
content: string;
@Field(() => Int)
estimatedTokenCount: number;
}
@ObjectType('AISystemPromptPreview')
export class AISystemPromptPreviewDTO {
@Field(() => [AISystemPromptSectionDTO])
sections: AISystemPromptSectionDTO[];
@Field(() => Int)
estimatedTokenCount: number;
}
@@ -42,6 +42,9 @@ export class AgentChatThreadEntity {
@Column({ type: 'int', nullable: true })
contextWindowTokens: number | null;
@Column({ type: 'int', default: 0 })
conversationSize: number;
@Column({ type: 'bigint', default: 0 })
totalInputCredits: number;
@@ -1,11 +1,22 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import {
Args,
Float,
Mutation,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { PermissionFlagType } from 'twenty-shared/constants';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
@@ -14,16 +25,22 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentMessageDTO } from 'src/engine/metadata-modules/ai/ai-agent-execution/dtos/agent-message.dto';
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/agent-chat-thread.dto';
import { AISystemPromptPreviewDTO } from 'src/engine/metadata-modules/ai/ai-chat/dtos/ai-system-prompt-preview.dto';
import { type AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatService } from 'src/engine/metadata-modules/ai/ai-chat/services/agent-chat.service';
import { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
@UseGuards(
WorkspaceAuthGuard,
FeatureFlagGuard,
SettingsPermissionGuard(PermissionFlagType.AI),
)
@Resolver()
@Resolver(() => AgentChatThreadDTO)
export class AgentChatResolver {
constructor(private readonly agentChatService: AgentChatService) {}
constructor(
private readonly agentChatService: AgentChatService,
private readonly systemPromptBuilderService: SystemPromptBuilderService,
) {}
@Query(() => [AgentChatThreadDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@@ -57,4 +74,27 @@ export class AgentChatResolver {
async createChatThread(@AuthUserWorkspaceId() userWorkspaceId: string) {
return this.agentChatService.createThread(userWorkspaceId);
}
@Query(() => AISystemPromptPreviewDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async getAISystemPromptPreview(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
) {
return this.systemPromptBuilderService.buildPreview(
workspace.id,
userWorkspaceId,
workspace.aiAdditionalInstructions ?? undefined,
);
}
@ResolveField(() => Float)
totalInputCredits(@Parent() thread: AgentChatThreadEntity): number {
return toDisplayCredits(thread.totalInputCredits);
}
@ResolveField(() => Float)
totalOutputCredits(@Parent() thread: AgentChatThreadEntity): number {
return toDisplayCredits(thread.totalOutputCredits);
}
}
@@ -17,6 +17,7 @@ import {
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { type BrowsingContextType } from 'src/engine/metadata-modules/ai/ai-agent/types/browsingContext.type';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util';
import { toDisplayCredits } from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatService } from './agent-chat.service';
@@ -84,21 +85,14 @@ export class AgentChatStreamingService {
onCodeExecutionUpdate,
});
writer.write({
type: 'data-routing-status' as const,
id: 'execution-status',
data: {
text: 'Processing your request...',
state: 'loading',
},
});
let streamUsage = {
inputTokens: 0,
outputTokens: 0,
inputCredits: 0,
outputCredits: 0,
};
let lastStepConversationSize = 0;
let totalCacheCreationTokens = 0;
writer.merge(
stream.toUIMessageStream({
@@ -109,9 +103,37 @@ export class AgentChatStreamingService {
},
sendStart: false,
messageMetadata: ({ part }) => {
if (part.type === 'finish-step') {
const stepInput = part.usage?.inputTokens ?? 0;
const stepCached = part.usage?.cachedInputTokens ?? 0;
// Anthropic excludes cached/created tokens from input_tokens,
// reporting them separately as cache_creation_input_tokens
const anthropicUsage = (
part as {
providerMetadata?: {
anthropic?: {
usage?: { cache_creation_input_tokens?: number };
};
};
}
).providerMetadata?.anthropic?.usage;
const stepCacheCreation =
anthropicUsage?.cache_creation_input_tokens ?? 0;
totalCacheCreationTokens += stepCacheCreation;
lastStepConversationSize =
stepInput + stepCached + stepCacheCreation;
}
if (part.type === 'finish') {
const inputTokens = part.totalUsage?.inputTokens ?? 0;
const inputTokens =
(part.totalUsage?.inputTokens ?? 0) +
(part.totalUsage?.cachedInputTokens ?? 0) +
totalCacheCreationTokens;
const outputTokens = part.totalUsage?.outputTokens ?? 0;
const cachedInputTokens =
part.totalUsage?.cachedInputTokens ?? 0;
const inputCostInCents =
(inputTokens / 1000) *
@@ -139,8 +161,10 @@ export class AgentChatStreamingService {
usage: {
inputTokens,
outputTokens,
inputCredits,
outputCredits,
cachedInputTokens,
inputCredits: toDisplayCredits(inputCredits),
outputCredits: toDisplayCredits(outputCredits),
conversationSize: lastStepConversationSize,
},
model: {
contextWindowTokens: modelConfig.contextWindowTokens,
@@ -155,15 +179,6 @@ export class AgentChatStreamingService {
return;
}
writer.write({
type: 'data-routing-status' as const,
id: 'execution-status',
data: {
text: 'Completed',
state: 'routed',
},
});
const validThreadId = thread.id;
if (!validThreadId) {
@@ -205,6 +220,7 @@ export class AgentChatStreamingService {
totalOutputCredits: () =>
`"totalOutputCredits" + ${streamUsage.outputCredits}`,
contextWindowTokens: modelConfig.contextWindowTokens,
conversationSize: lastStepConversationSize,
});
} catch (saveError) {
this.logger.error(
@@ -1,11 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { groq } from '@ai-sdk/groq';
import { openai } from '@ai-sdk/openai';
import {
convertToModelMessages,
stepCountIs,
streamText,
type SystemModelMessage,
type ToolSet,
type UIDataTypes,
type UIMessage,
@@ -17,16 +19,16 @@ import { getAppPath } from 'twenty-shared/utils';
import { type CodeExecutionStreamEmitter } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { wrapToolsWithOutputSerialization } from 'src/engine/core-modules/tool-provider/output-serialization/wrap-tools-with-output-serialization.util';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
type ToolIndexEntry,
ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import {
createExecuteToolTool,
createLearnToolsTool,
createLoadSkillTool,
createLoadToolsTool,
type DynamicToolStore,
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
LOAD_SKILL_TOOL_NAME,
LOAD_TOOLS_TOOL_NAME,
} 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';
@@ -34,7 +36,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/
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 { SystemPromptBuilderService } from 'src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service';
import {
extractCodeInterpreterFiles,
type ExtractedFile,
@@ -45,7 +47,6 @@ import {
} 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 { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type ChatExecutionOptions = {
@@ -58,12 +59,9 @@ export type ChatExecutionOptions = {
export type ChatExecutionResult = {
stream: ReturnType<typeof streamText>;
preloadedTools: string[];
modelConfig: AIModelConfig;
};
const COMMON_PRELOAD_TOOLS = ['search_help_center'];
@Injectable()
export class ChatExecutionService {
private readonly logger = new Logger(ChatExecutionService.name);
@@ -75,6 +73,7 @@ export class ChatExecutionService {
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly systemPromptBuilder: SystemPromptBuilderService,
) {}
async streamChat({
@@ -84,7 +83,7 @@ export class ChatExecutionService {
browsingContext,
onCodeExecutionUpdate,
}: ChatExecutionOptions): Promise<ChatExecutionResult> {
const { actorContext, roleId, userId } =
const { actorContext, roleId, userId, userContext } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
@@ -124,33 +123,35 @@ export class ChatExecutionService {
const preloadedToolNames = Object.keys(preloadedTools);
const dynamicToolStore: DynamicToolStore = {
loadedTools: new Set(preloadedToolNames),
};
// Respect the workspace's model preference (Settings > AI > Model Router)
const registeredModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
await this.aiModelRegistryService.resolveModelForAgent({
modelId: workspace.smartModel,
});
const modelConfig = this.aiModelRegistryService.getEffectiveModelConfig(
registeredModel.modelId,
);
const activeTools: ToolSet = {
...preloadedTools,
// Direct tools: native provider tools + preloaded tools.
// These are callable directly AND as fallback through execute_tool.
const directTools: ToolSet = {
...wrapToolsWithOutputSerialization(preloadedTools),
...this.getNativeWebSearchTool(registeredModel.provider),
[LOAD_TOOLS_TOOL_NAME]: createLoadToolsTool(
};
// ToolSet is constant for the entire conversation — no mutation.
// learn_tools returns schemas as text; execute_tool dispatches to cached tools.
const activeTools: ToolSet = {
...directTools,
[LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
this.toolRegistry,
toolContext,
dynamicToolStore,
async (toolNames) => {
const newTools = await this.toolRegistry.getToolsByName(
toolNames,
toolContext,
);
Object.assign(activeTools, newTools);
this.logger.log(`Dynamically loaded tools: ${toolNames.join(', ')}`);
},
),
[EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
this.toolRegistry,
toolContext,
directTools,
),
[LOAD_SKILL_TOOL_NAME]: createLoadSkillTool((skillNames) =>
this.skillService.findFlatSkillsByNames(skillNames, workspace.id),
@@ -173,22 +174,32 @@ export class ChatExecutionService {
);
}
const systemPrompt = this.buildSystemPrompt(
const systemPrompt = this.systemPromptBuilder.buildFullPrompt(
toolCatalog,
skillCatalog,
preloadedToolNames,
contextString,
storedFiles,
workspace.aiAdditionalInstructions ?? undefined,
userContext,
);
this.logger.log(
`Starting chat execution with model ${registeredModel.modelId}, ${Object.keys(activeTools).length} active tools`,
);
const systemMessage: SystemModelMessage = {
role: 'system',
content: systemPrompt,
providerOptions:
registeredModel.provider === ModelProvider.ANTHROPIC
? { anthropic: { cacheControl: { type: 'ephemeral' } } }
: undefined,
};
const stream = streamText({
model: registeredModel.model,
system: systemPrompt,
messages: convertToModelMessages(processedMessages),
messages: [systemMessage, ...convertToModelMessages(processedMessages)],
tools: activeTools,
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
experimental_telemetry: AI_TELEMETRY_CONFIG,
@@ -223,7 +234,6 @@ export class ChatExecutionService {
return {
stream,
preloadedTools: preloadedToolNames,
modelConfig,
};
}
@@ -284,176 +294,18 @@ export class ChatExecutionService {
return context;
}
private buildSystemPrompt(
toolCatalog: ToolIndexEntry[],
skillCatalog: FlatSkill[],
preloadedTools: string[],
contextString?: string,
storedFiles?: Array<{ filename: string; storagePath: string; url: string }>,
): string {
const parts: string[] = [
CHAT_SYSTEM_PROMPTS.BASE,
CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT,
];
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
parts.push(this.buildSkillCatalogSection(skillCatalog));
if (storedFiles && storedFiles.length > 0) {
parts.push(this.buildUploadedFilesSection(storedFiles));
}
if (contextString) {
parts.push(
`\nCONTEXT (what the user is currently viewing):\n${contextString}`,
);
}
return parts.join('\n');
}
private buildUploadedFilesSection(
storedFiles: Array<{ filename: string; storagePath: string; url: string }>,
): string {
const fileList = storedFiles.map((f) => `- ${f.filename}`).join('\n');
const filesJson = JSON.stringify(
storedFiles.map((f) => ({ filename: f.filename, url: f.url })),
);
return `
## Uploaded Files
The user has uploaded the following files:
${fileList}
**IMPORTANT**: Use the \`code_interpreter\` tool to analyze these files.
When calling code_interpreter, include the files parameter with these values:
\`\`\`json
${filesJson}
\`\`\`
In your Python code, access files at \`/home/user/{filename}\`.`;
}
private buildSkillCatalogSection(skillCatalog: FlatSkill[]): string {
if (skillCatalog.length === 0) {
return '';
}
const skillsList = skillCatalog
.map(
(skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`,
)
.join('\n');
return `
## Available Skills
Skills provide detailed expertise for specialized tasks. Load a skill before attempting complex operations.
To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s).
${skillsList}`;
}
private buildToolCatalogSection(
toolCatalog: ToolIndexEntry[],
preloadedTools: string[],
): string {
const preloadedSet = new Set(preloadedTools);
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
for (const tool of toolCatalog) {
const category = tool.category;
const existing = toolsByCategory.get(category) ?? [];
existing.push(tool);
toolsByCategory.set(category, existing);
}
const sections: string[] = [];
sections.push(`
## Available Tools
You have access to ${toolCatalog.length} tools plus native web search. Some are pre-loaded and ready to use immediately.
To use a tool that isn't pre-loaded, call \`${LOAD_TOOLS_TOOL_NAME}\` with the exact tool name(s) first.
### Pre-loaded Tools (ready to use now)
- \`web_search\` ✓: Search the web for real-time information (ALWAYS use this for current data, news, research)
${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\``).join('\n') : ''}
### Tool Catalog by Category`);
const categoryOrder = [
'DATABASE',
'ACTION',
'WORKFLOW',
'DASHBOARD',
'METADATA',
'VIEW',
'LOGIC_FUNCTION',
];
for (const category of categoryOrder) {
const tools = toolsByCategory.get(category);
if (!tools || tools.length === 0) {
continue;
}
const categoryLabel = this.getCategoryLabel(category);
sections.push(`
#### ${categoryLabel} (${tools.length} tools)
${tools
.map((t) => {
const status = preloadedSet.has(t.name) ? ' ✓' : '';
return `- \`${t.name}\`${status}: ${t.description}`;
})
.join('\n')}`);
}
sections.push(`
### How to Use Tools
1. **Web search** (\`web_search\`): Use for ANY request requiring current/real-time information from the internet
2. **Pre-loaded tools** (marked with ✓): Use directly
3. **Other tools**: First call \`${LOAD_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\`, then use the tool`);
return sections.join('\n');
}
private getCategoryLabel(category: string): string {
switch (category) {
case 'DATABASE':
return 'Database Tools (CRUD operations)';
case 'ACTION':
return 'Action Tools (HTTP, Email, etc.)';
case 'WORKFLOW':
return 'Workflow Tools (create/manage workflows)';
case 'METADATA':
return 'Metadata Tools (schema management)';
case 'VIEW':
return 'View Tools (query views)';
case 'DASHBOARD':
return 'Dashboard Tools (create/manage dashboards)';
case 'LOGIC_FUNCTION':
return 'Logic Functions (custom tools)';
default:
return category;
}
}
private getNativeWebSearchTool(provider: ModelProvider): ToolSet {
switch (provider) {
case ModelProvider.ANTHROPIC:
return { web_search: anthropic.tools.webSearch_20250305() };
case ModelProvider.OPENAI:
return { web_search: openai.tools.webSearch() };
case ModelProvider.GROQ:
// Type assertion needed due to @ai-sdk/groq tool type mismatch
return {
web_search: groq.tools.browserSearch({}) as ToolSet[string],
};
default:
// Other providers don't have native web search
return {};
}
}
@@ -0,0 +1,333 @@
import { Injectable } from '@nestjs/common';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import {
EXECUTE_TOOL_TOOL_NAME,
LEARN_TOOLS_TOOL_NAME,
LOAD_SKILL_TOOL_NAME,
} from 'src/engine/core-modules/tool-provider/tools';
import {
AgentActorContextService,
type UserContext,
} from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { CHAT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
import { SkillService } from 'src/engine/metadata-modules/skill/skill.service';
export type SystemPromptSection = {
title: string;
content: string;
estimatedTokenCount: number;
};
export type SystemPromptPreview = {
sections: SystemPromptSection[];
estimatedTokenCount: number;
};
// ~4 characters per token for mixed English/code content
const estimateTokenCount = (text: string): number => Math.ceil(text.length / 4);
@Injectable()
export class SystemPromptBuilderService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly skillService: SkillService,
private readonly agentActorContextService: AgentActorContextService,
) {}
async buildPreview(
workspaceId: string,
userWorkspaceId: string,
workspaceInstructions?: string,
): Promise<SystemPromptPreview> {
const { roleId, userId, userContext } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspaceId,
);
const toolCatalog = await this.toolRegistry.buildToolIndex(
workspaceId,
roleId,
{ userId, userWorkspaceId },
);
const skillCatalog = await this.skillService.findAllFlatSkills(workspaceId);
const sections: SystemPromptSection[] = [];
const baseContent = CHAT_SYSTEM_PROMPTS.BASE;
sections.push({
title: 'Base Instructions',
content: baseContent,
estimatedTokenCount: estimateTokenCount(baseContent),
});
const responseFormatContent = CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT;
sections.push({
title: 'Response Format',
content: responseFormatContent,
estimatedTokenCount: estimateTokenCount(responseFormatContent),
});
if (workspaceInstructions) {
const workspaceSection = this.buildWorkspaceInstructionsSection(
workspaceInstructions,
);
sections.push({
title: 'Workspace Instructions',
content: workspaceSection,
estimatedTokenCount: estimateTokenCount(workspaceSection),
});
}
if (userContext) {
const userSection = this.buildUserContextSection(userContext);
sections.push({
title: 'User Context',
content: userSection,
estimatedTokenCount: estimateTokenCount(userSection),
});
}
const toolSection = this.buildToolCatalogSection(
toolCatalog,
COMMON_PRELOAD_TOOLS,
);
sections.push({
title: 'Tool Catalog',
content: toolSection,
estimatedTokenCount: estimateTokenCount(toolSection),
});
const skillSection = this.buildSkillCatalogSection(skillCatalog);
if (skillSection) {
sections.push({
title: 'Skill Catalog',
content: skillSection,
estimatedTokenCount: estimateTokenCount(skillSection),
});
}
const totalTokens = sections.reduce(
(sum, section) => sum + section.estimatedTokenCount,
0,
);
return {
sections,
estimatedTokenCount: totalTokens,
};
}
buildFullPrompt(
toolCatalog: ToolDescriptor[],
skillCatalog: FlatSkill[],
preloadedTools: string[],
contextString?: string,
storedFiles?: Array<{
filename: string;
storagePath: string;
url: string;
}>,
workspaceInstructions?: string,
userContext?: UserContext,
): string {
const parts: string[] = [
CHAT_SYSTEM_PROMPTS.BASE,
CHAT_SYSTEM_PROMPTS.RESPONSE_FORMAT,
];
if (workspaceInstructions) {
parts.push(this.buildWorkspaceInstructionsSection(workspaceInstructions));
}
if (userContext) {
parts.push(this.buildUserContextSection(userContext));
}
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
parts.push(this.buildSkillCatalogSection(skillCatalog));
if (storedFiles && storedFiles.length > 0) {
parts.push(this.buildUploadedFilesSection(storedFiles));
}
if (contextString) {
parts.push(
`\nCONTEXT (what the user is currently viewing):\n${contextString}`,
);
}
return parts.join('\n');
}
buildWorkspaceInstructionsSection(instructions: string): string {
return `
## Workspace Instructions
The following are custom instructions provided by the workspace administrator:
${instructions}`;
}
buildUserContextSection(userContext: UserContext): string {
const parts = [
`User: ${userContext.firstName} ${userContext.lastName}`.trim(),
`Locale: ${userContext.locale}`,
];
if (userContext.timezone) {
parts.push(`Timezone: ${userContext.timezone}`);
}
return `
## User Context
${parts.join('\n')}`;
}
buildUploadedFilesSection(
storedFiles: Array<{ filename: string; storagePath: string; url: string }>,
): string {
const fileList = storedFiles.map((f) => `- ${f.filename}`).join('\n');
const filesJson = JSON.stringify(
storedFiles.map((f) => ({ filename: f.filename, url: f.url })),
);
return `
## Uploaded Files
The user has uploaded the following files:
${fileList}
**IMPORTANT**: Use the \`code_interpreter\` tool to analyze these files.
When calling code_interpreter, include the files parameter with these values:
\`\`\`json
${filesJson}
\`\`\`
In your Python code, access files at \`/home/user/{filename}\`.`;
}
buildSkillCatalogSection(skillCatalog: FlatSkill[]): string {
if (skillCatalog.length === 0) {
return '';
}
const skillsList = skillCatalog
.map(
(skill) => `- \`${skill.name}\`: ${skill.description ?? skill.label}`,
)
.join('\n');
return `
## Available Skills
Skills provide detailed expertise for specialized tasks. Load a skill before attempting complex operations.
To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s).
${skillsList}`;
}
buildToolCatalogSection(
toolCatalog: ToolDescriptor[],
preloadedTools: string[],
): string {
const preloadedSet = new Set(preloadedTools);
const toolsByCategory = new Map<string, ToolDescriptor[]>();
for (const tool of toolCatalog) {
const category = tool.category;
const existing = toolsByCategory.get(category) ?? [];
existing.push(tool);
toolsByCategory.set(category, existing);
}
const sections: string[] = [];
sections.push(`
## Available Tools
You have access to ${toolCatalog.length} tools plus native web search. Some are pre-loaded and ready to use immediately.
To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it.
### Pre-loaded Tools (ready to use now)
- \`web_search\` ✓: Search the web for real-time information (ALWAYS use this for current data, news, research)
${preloadedTools.length > 0 ? preloadedTools.map((toolName) => `- \`${toolName}\``).join('\n') : ''}
### Tool Catalog by Category`);
const categoryOrder = [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.WORKFLOW,
ToolCategory.DASHBOARD,
ToolCategory.METADATA,
ToolCategory.VIEW,
ToolCategory.LOGIC_FUNCTION,
];
for (const category of categoryOrder) {
const tools = toolsByCategory.get(category);
if (!tools || tools.length === 0) {
continue;
}
const categoryLabel = this.getCategoryLabel(category);
sections.push(`
#### ${categoryLabel} (${tools.length} tools)
${tools
.map((tool) => {
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
return `- \`${tool.name}\`${status}`;
})
.join('\n')}`);
}
sections.push(`
### How to Use Tools
1. **Web search** (\`web_search\`): Use for ANY request requiring current/real-time information from the internet
2. **Pre-loaded tools** (marked with ✓): Use directly
3. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`);
return sections.join('\n');
}
private getCategoryLabel(category: string): string {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return 'Database Tools (CRUD operations)';
case ToolCategory.ACTION:
return 'Action Tools (HTTP, Email, etc.)';
case ToolCategory.WORKFLOW:
return 'Workflow Tools (create/manage workflows)';
case ToolCategory.METADATA:
return 'Metadata Tools (schema management)';
case ToolCategory.VIEW:
return 'View Tools (query views)';
case ToolCategory.DASHBOARD:
return 'Dashboard Tools (create/manage dashboards)';
case ToolCategory.LOGIC_FUNCTION:
return 'Logic Functions (custom tools)';
default:
return category;
}
}
}
@@ -4,6 +4,7 @@ export enum ModelProvider {
ANTHROPIC = 'anthropic',
OPENAI_COMPATIBLE = 'open_ai_compatible',
XAI = 'xai',
GROQ = 'groq',
}
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
@@ -32,6 +33,8 @@ export type ModelId =
| 'grok-3-mini'
| 'grok-4'
| 'grok-4-1-fast-reasoning'
// Groq models
| 'openai/gpt-oss-120b'
| string; // Allow custom model names
export type SupportedFileType =
@@ -15,6 +15,7 @@ describe('AI_MODELS', () => {
ModelProvider.OPENAI,
ModelProvider.ANTHROPIC,
ModelProvider.XAI,
ModelProvider.GROQ,
];
providers.forEach((provider) => {
@@ -51,6 +52,7 @@ describe('AI_MODELS', () => {
ModelProvider.OPENAI,
ModelProvider.ANTHROPIC,
ModelProvider.XAI,
ModelProvider.GROQ,
];
providers.forEach((provider) => {
@@ -89,7 +91,7 @@ describe('AiModelRegistryService', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).',
);
});
@@ -9,6 +9,7 @@ export {
import { type AIModelConfig } from './ai-models-types.const';
import { ANTHROPIC_MODELS } from './anthropic-models.const';
import { GROQ_MODELS } from './groq-models.const';
import { OPENAI_MODELS } from './openai-models.const';
import { XAI_MODELS } from './xai-models.const';
@@ -16,4 +17,5 @@ export const AI_MODELS: AIModelConfig[] = [
...OPENAI_MODELS,
...ANTHROPIC_MODELS,
...XAI_MODELS,
...GROQ_MODELS,
];
@@ -0,0 +1,15 @@
import { type AIModelConfig, ModelProvider } from './ai-models-types.const';
export const GROQ_MODELS: AIModelConfig[] = [
{
modelId: 'openai/gpt-oss-120b',
label: 'GPT-OSS 120B (Groq)',
description:
'Large-scale open-source model with browser search, served via Groq inference',
provider: ModelProvider.GROQ,
inputCostPer1kTokensInCents: 0.059,
outputCostPer1kTokensInCents: 0.079,
contextWindowTokens: 128000,
maxOutputTokens: 16384,
},
];
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { groq } from '@ai-sdk/groq';
import { createOpenAI, openai } from '@ai-sdk/openai';
import { xai } from '@ai-sdk/xai';
import { type LanguageModel } from 'ai';
@@ -18,6 +19,7 @@ import {
type AIModelConfig,
} from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { ANTHROPIC_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/anthropic-models.const';
import { GROQ_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/groq-models.const';
import { OPENAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/openai-models.const';
import { XAI_MODELS } from 'src/engine/metadata-modules/ai/ai-models/constants/xai-models.const';
@@ -57,6 +59,12 @@ export class AiModelRegistryService {
this.registerXaiModels();
}
const groqApiKey = this.twentyConfigService.get('GROQ_API_KEY');
if (groqApiKey) {
this.registerGroqModels();
}
const openaiCompatibleBaseUrl = this.twentyConfigService.get(
'OPENAI_COMPATIBLE_BASE_URL',
);
@@ -105,6 +113,17 @@ export class AiModelRegistryService {
});
}
private registerGroqModels(): void {
GROQ_MODELS.forEach((modelConfig) => {
this.modelRegistry.set(modelConfig.modelId, {
modelId: modelConfig.modelId,
provider: ModelProvider.GROQ,
model: groq(modelConfig.modelId),
doesSupportThinking: modelConfig.doesSupportThinking,
});
});
}
private registerOpenAICompatibleModels(
baseUrl: string,
modelNamesString: string,
@@ -170,7 +189,7 @@ export class AiModelRegistryService {
if (!model) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
@@ -192,7 +211,7 @@ export class AiModelRegistryService {
if (!model) {
throw new AgentException(
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, or XAI_API_KEY).',
'No AI models are available. Please configure at least one AI provider API key (OPENAI_API_KEY, ANTHROPIC_API_KEY, XAI_API_KEY, or GROQ_API_KEY).',
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
@@ -290,6 +309,9 @@ export class AiModelRegistryService {
case ModelProvider.XAI:
apiKey = this.twentyConfigService.get('XAI_API_KEY');
break;
case ModelProvider.GROQ:
apiKey = this.twentyConfigService.get('GROQ_API_KEY');
break;
case ModelProvider.OPENAI_COMPATIBLE:
apiKey = this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY');
break;
@@ -17,6 +17,7 @@ export const METADATA_VERSIONED_WORKSPACE_CACHE_KEY = {
MetadataObjectMetadataMaps: 'metadata:object-metadata-maps',
GraphQLUsedScalarNames: 'graphql:used-scalar-names',
ORMEntitySchemas: 'orm:entity-schemas',
ToolCatalog: 'tool-catalog',
} as const;
export const WORKSPACE_CACHE_KEYS = {
GraphQLOperations: 'graphql:operations',
@@ -202,6 +203,24 @@ export class WorkspaceCacheStorageService {
);
}
setToolCatalog(
cacheKey: string,
descriptors: unknown[],
ttl: number,
): Promise<void> {
return this.cacheStorageService.set<unknown[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`,
descriptors,
ttl,
);
}
getToolCatalog(cacheKey: string): Promise<unknown[] | undefined> {
return this.cacheStorageService.get<unknown[]>(
`${METADATA_VERSIONED_WORKSPACE_CACHE_KEY.ToolCatalog}:${cacheKey}`,
);
}
async flush(workspaceId: string, metadataVersion?: number): Promise<void> {
await this.flushVersionedMetadata(workspaceId, metadataVersion);
@@ -1,94 +0,0 @@
import { type ToolSet } from 'ai';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildCreateRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Create ${objectMetadata.labelSingular}")`,
),
input: recordPropertiesSchema.describe(
`The ${objectMetadata.labelSingular} record data. Use {{trigger.fieldName}} or {{stepId.fieldName}} syntax to reference dynamic values from previous steps.`,
),
});
return {
[`configure_create_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that creates a ${objectMetadata.labelSingular} record. ` +
`Provide the record fields directly - use {{trigger.fieldName}} or {{stepId.fieldName}} to reference values from previous steps.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.CREATE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Create ${objectMetadata.labelSingular}`,
type: WorkflowActionType.CREATE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to create ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,87 +0,0 @@
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildDeleteRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
context: ToolGeneratorContext,
): ToolSet {
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Delete ${objectMetadata.labelSingular}")`,
),
objectRecordId: z
.string()
.describe(
`The ID of the ${objectMetadata.labelSingular} record to delete. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`,
),
});
return {
[`configure_delete_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that deletes a ${objectMetadata.labelSingular} record. ` +
`This performs a soft delete (marks as deleted but preserves data).`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.DELETE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Delete ${objectMetadata.labelSingular}`,
type: WorkflowActionType.DELETE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.objectRecordId,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to delete ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} delete workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,149 +0,0 @@
import { type ToolSet } from 'ai';
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildFindRecordsStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const filterSchema = generateFieldFilterZodSchema(field);
if (!filterSchema) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
filterSchema;
});
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Find ${objectMetadata.labelPlural}")`,
),
limit: z
.number()
.int()
.positive()
.max(1000)
.optional()
.default(100)
.describe('Maximum number of records to return (default: 100)'),
orderBy: ObjectRecordOrderBySchema.optional().describe(
'Sort records by field(s). Each item is an object with field name as key, sort direction as value.',
),
filter: z
.object(filterShape)
.partial()
.optional()
.describe(
`Filter criteria for ${objectMetadata.labelPlural}. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`,
),
});
return {
[`configure_find_${objectMetadata.namePlural}_step`]: {
description:
`Add a workflow step that searches for ${objectMetadata.labelPlural} records. ` +
`Results can be used in subsequent steps via {{stepId.result}}.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const filterConfig = parameters.filter
? {
recordFilters: Object.entries(parameters.filter).map(
([fieldName, filterValue]) => ({
fieldName,
filter: filterValue,
}),
),
}
: undefined;
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.FIND_RECORDS,
parameters.parentStepId,
{
name: parameters.stepName || `Find ${objectMetadata.labelPlural}`,
type: WorkflowActionType.FIND_RECORDS,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
limit: parameters.limit,
filter: filterConfig,
orderBy: parameters.orderBy
? { gqlOperationOrderBy: parameters.orderBy }
: undefined,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to find ${objectMetadata.labelPlural}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelPlural} find workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,52 +0,0 @@
import { v4 as uuidv4 } from 'uuid';
import { type WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
export type WorkflowStepToolsDeps = {
workflowVersionStepService: {
createWorkflowVersionStep: (args: {
workspaceId: string;
input: {
workflowVersionId: string;
stepType: WorkflowActionType;
parentStepId?: string;
id?: string;
};
}) => Promise<unknown>;
updateWorkflowVersionStep: (args: {
workspaceId: string;
workflowVersionId: string;
step: unknown;
}) => Promise<unknown>;
};
};
export async function createAndConfigureStep(
deps: WorkflowStepToolsDeps,
workspaceId: string,
workflowVersionId: string,
stepType: WorkflowActionType,
parentStepId: string | undefined,
stepConfig: object,
) {
const stepId = uuidv4();
await deps.workflowVersionStepService.createWorkflowVersionStep({
workspaceId,
input: {
workflowVersionId,
stepType,
parentStepId,
id: stepId,
},
});
const result =
await deps.workflowVersionStepService.updateWorkflowVersionStep({
workspaceId,
workflowVersionId,
step: { id: stepId, ...stepConfig },
});
return { stepId, result };
}
@@ -1,101 +0,0 @@
import { type ToolSet } from 'ai';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
import { type ToolGeneratorContext } from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-type.enum';
import {
createAndConfigureStep,
type WorkflowStepToolsDeps,
} from './step-builder.utils';
export function buildUpdateRecordStepTool(
deps: WorkflowStepToolsDeps,
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields: RestrictedFieldsPermissions,
context: ToolGeneratorContext,
): ToolSet {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
const updateSchema = recordPropertiesSchema.partial();
const inputSchema = z.object({
workflowVersionId: z
.string()
.describe('The ID of the workflow version to add the step to'),
parentStepId: z
.string()
.optional()
.describe('Optional ID of the parent step this step should come after'),
stepName: z
.string()
.optional()
.describe(
`Name for this step (default: "Update ${objectMetadata.labelSingular}")`,
),
objectRecordId: z
.string()
.describe(
`The ID of the ${objectMetadata.labelSingular} record to update. Use {{trigger.id}} or {{stepId.result.id}} to reference a dynamic ID.`,
),
fieldsToUpdate: updateSchema.describe(
`The fields to update on the ${objectMetadata.labelSingular} record. Only include fields you want to change. Use {{trigger.fieldName}} or {{stepId.fieldName}} to reference dynamic values.`,
),
});
return {
[`configure_update_${objectMetadata.nameSingular}_step`]: {
description:
`Add a workflow step that updates an existing ${objectMetadata.labelSingular} record. ` +
`Specify the record ID and only the fields you want to update.`,
inputSchema,
execute: async (parameters: z.infer<typeof inputSchema>) => {
try {
const { stepId, result } = await createAndConfigureStep(
deps,
context.workspaceId,
parameters.workflowVersionId,
WorkflowActionType.UPDATE_RECORD,
parameters.parentStepId,
{
name:
parameters.stepName || `Update ${objectMetadata.labelSingular}`,
type: WorkflowActionType.UPDATE_RECORD,
valid: true,
settings: {
input: {
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.objectRecordId,
objectRecord: parameters.fieldsToUpdate,
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
},
},
);
return {
success: true,
message: `Created workflow step to update ${objectMetadata.labelSingular}`,
result: { stepId, step: result },
};
} catch (error) {
return {
success: false,
error: error.message,
message: `Failed to create ${objectMetadata.labelSingular} update workflow step: ${error.message}`,
};
}
},
},
};
}
@@ -1,75 +0,0 @@
import { type ToolSet } from 'ai';
import {
type ObjectWithPermission,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { buildCreateRecordStepTool } from './builders/create-record-step.builder';
import { buildDeleteRecordStepTool } from './builders/delete-record-step.builder';
import { buildFindRecordsStepTool } from './builders/find-records-step.builder';
import { type WorkflowStepToolsDeps } from './builders/step-builder.utils';
import { buildUpdateRecordStepTool } from './builders/update-record-step.builder';
export { type WorkflowStepToolsDeps } from './builders/step-builder.utils';
export const createWorkflowStepToolsFactory = (deps: WorkflowStepToolsDeps) => {
return (
{
objectMetadata,
restrictedFields,
canCreate,
canRead,
canUpdate,
canDelete,
}: ObjectWithPermission,
context: ToolGeneratorContext,
): ToolSet => {
const tools: ToolSet = {};
if (canCreate) {
Object.assign(
tools,
buildCreateRecordStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canUpdate) {
Object.assign(
tools,
buildUpdateRecordStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canRead) {
Object.assign(
tools,
buildFindRecordsStepTool(
deps,
objectMetadata,
restrictedFields,
context,
),
);
}
if (canDelete) {
Object.assign(
tools,
buildDeleteRecordStepTool(deps, objectMetadata, context),
);
}
return tools;
};
};
@@ -3,7 +3,6 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkflowSchemaWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.workspace-service';
@@ -11,10 +10,6 @@ import { WorkflowVersionEdgeWorkspaceService } from 'src/modules/workflow/workfl
import { WorkflowVersionStepHelpersWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-helpers.workspace-service';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service';
import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service';
import {
createWorkflowStepToolsFactory,
type WorkflowStepToolsDeps,
} from 'src/modules/workflow/workflow-tools/factories/workflow-step-tools.factory';
import { createActivateWorkflowVersionTool } from 'src/modules/workflow/workflow-tools/tools/activate-workflow-version.tool';
import { createComputeStepOutputSchemaTool } from 'src/modules/workflow/workflow-tools/tools/compute-step-output-schema.tool';
import { createCreateCompleteWorkflowTool } from 'src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool';
@@ -34,7 +29,6 @@ import { WorkflowTriggerWorkspaceService } from 'src/modules/workflow/workflow-t
@Injectable()
export class WorkflowToolWorkspaceService {
private readonly deps: WorkflowToolDependencies;
private readonly workflowStepToolsDeps: WorkflowStepToolsDeps;
constructor(
workflowVersionStepService: WorkflowVersionStepWorkspaceService,
@@ -45,7 +39,6 @@ export class WorkflowToolWorkspaceService {
workflowSchemaService: WorkflowSchemaWorkspaceService,
globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
recordPositionService: RecordPositionService,
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
) {
this.deps = {
workflowVersionStepService,
@@ -57,10 +50,6 @@ export class WorkflowToolWorkspaceService {
globalWorkspaceOrmManager,
recordPositionService,
};
this.workflowStepToolsDeps = {
workflowVersionStepService,
};
}
// Generates static workflow tools that don't depend on workspace objects
@@ -136,22 +125,4 @@ export class WorkflowToolWorkspaceService {
[getWorkflowCurrentVersion.name]: getWorkflowCurrentVersion,
};
}
// Generates dynamic step configurator tools for each workspace object
async generateRecordStepConfiguratorTools(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): Promise<ToolSet> {
const workflowStepToolsFactory = createWorkflowStepToolsFactory(
this.workflowStepToolsDeps,
);
return this.perObjectToolGenerator.generate(
{
workspaceId,
rolePermissionConfig,
},
[workflowStepToolsFactory],
);
}
}
@@ -1,7 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module';
import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
import { WorkflowVersionEdgeModule } from 'src/modules/workflow/workflow-builder/workflow-version-edge/workflow-version-edge.module';
@@ -22,7 +21,6 @@ import { WorkflowToolWorkspaceService } from './services/workflow-tool.workspace
WorkflowTriggerModule,
WorkflowSchemaModule,
RecordPositionModule,
ToolGeneratorModule,
],
providers: [
WorkflowToolWorkspaceService,
@@ -4,8 +4,10 @@ import { type UIMessage } from 'ai';
export type AIChatUsageMetadata = {
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
inputCredits: number;
outputCredits: number;
conversationSize: number;
};
export type AIChatModelMetadata = {
@@ -1,4 +1,4 @@
import { IsSerializedRelation } from "@/types/IsSerializedRelation.type";
import { type IsSerializedRelation } from '@/types/IsSerializedRelation.type';
export type ExtractSerializedRelationProperties<T> = T extends unknown
? T extends object
@@ -1,4 +1,4 @@
import { SERIALIZED_RELATION_BRAND } from '@/types/SerializedRelation.type';
import { type SERIALIZED_RELATION_BRAND } from '@/types/SerializedRelation.type';
export type IsSerializedRelation<T> =
typeof SERIALIZED_RELATION_BRAND extends keyof T ? true : false;
@@ -28,6 +28,7 @@ export enum SettingsPath {
EmailingDomainDetail = 'domains/emailing-domain/:domainId',
Updates = 'updates',
AI = 'ai',
AIPrompts = 'ai/prompts',
AINewAgent = 'ai/new-agent',
AIAgentDetail = 'ai/agents/:agentId',
AIAgentTurnDetail = 'ai/agents/:agentId/turns/:turnId',

Some files were not shown because too many files have changed in this diff Show More