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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 3245 additions and 1714 deletions

File diff suppressed because one or more lines are too long

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>;

View file

@ -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 {

View file

@ -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,
}

View file

@ -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);

View file

@ -82,8 +82,10 @@ print("Chart saved successfully!")`,
usage: {
inputTokens: 1250,
outputTokens: 890,
cachedInputTokens: 0,
inputCredits: 12,
outputCredits: 8,
conversationSize: 1250,
},
},
};

View file

@ -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>

View file

@ -8,6 +8,7 @@ export const GET_CHAT_THREADS = gql`
totalInputTokens
totalOutputTokens
contextWindowTokens
conversationSize
totalInputCredits
totalOutputCredits
createdAt

View file

@ -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,
}));

View file

@ -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,
}

View file

@ -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;
};

View file

@ -8,6 +8,7 @@ describe('groupThreadsByDate', () => {
totalInputTokens: 0,
totalOutputTokens: 0,
contextWindowTokens: null,
conversationSize: 0,
totalInputCredits: 0,
totalOutputCredits: 0,
};

View file

@ -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);
};

View file

@ -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,

View file

@ -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);
};

View file

@ -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" />}

View file

@ -36,6 +36,7 @@ export type CurrentWorkspace = Pick<
| 'eventLogRetentionDays'
| 'fastModel'
| 'smartModel'
| 'aiAdditionalInstructions'
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;

View file

@ -88,6 +88,7 @@ export const USER_QUERY_FRAGMENT = gql`
}
fastModel
smartModel
aiAdditionalInstructions
isTwoFactorAuthenticationEnforced
trashRetentionDays
eventLogRetentionDays

View file

@ -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
}
}
`;

View file

@ -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 />
</>
)}

View file

@ -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>
);
};

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",

View file

@ -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"`,
);
}
}

View file

@ -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"`,
);
}
}

View file

@ -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)

View file

@ -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;
}

View file

@ -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;

View file

@ -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,

View file

@ -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',
};
}
}
}

View file

@ -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,

View file

@ -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',
};
}
}
}

View file

@ -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,

View file

@ -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;
};
};

View file

@ -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;
};

View file

@ -4,4 +4,5 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
export type RecordCrudExecutionContext = {
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
slimResponse?: boolean;
};

View file

@ -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;
};

View file

@ -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.',
),
});
};

View file

@ -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.',
),
});
};

View file

@ -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>;

View file

@ -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()

View file

@ -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 };
};

View file

@ -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);
}

View file

@ -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>;

View file

@ -0,0 +1 @@
export const COMMON_PRELOAD_TOOLS: string[] = ['search_help_center'];

View file

@ -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>;
}

View file

@ -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();
});
});

View file

@ -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);
};

View file

@ -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;
};

View file

@ -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;
};

View file

@ -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 },
};
}
}

View file

@ -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);
}
}

View file

@ -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(

View file

@ -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, '_')

View file

@ -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);
}
}

View file

@ -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(

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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,
});
}
}

View file

@ -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(

View file

@ -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,

View file

@ -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);
},
});

View file

@ -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,

View file

@ -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(', ')}.`,
};
},
});

View file

@ -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

View file

@ -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.`,
};
},
});

View file

@ -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;
};

View file

@ -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 },
};
});
};

View file

@ -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',

View file

@ -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 })

View file

@ -84,6 +84,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
defaultRoleId: PermissionFlagType.ROLES,
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
aiAdditionalInstructions: PermissionFlagType.WORKSPACE,
};
constructor(

View file

@ -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;

View file

@ -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,
};
}
}

View file

@ -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],
);
};

View file

@ -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,

View file

@ -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: `

View file

@ -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()

View file

@ -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;
}

View file

@ -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;

View file

@ -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);
}
}

View file

@ -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(

View file

@ -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 {};
}
}

View file

@ -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;
}
}
}

View file

@ -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 =

View file

@ -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).',
);
});

View file

@ -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,
];

View file

@ -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,
},
];

View file

@ -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;

View file

@ -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);

View file

@ -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}`,
};
}
},
},
};
}

View file

@ -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}`,
};
}
},
},
};
}

View file

@ -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}`,
};
}
},
},
};
}

View file

@ -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 };
}

View file

@ -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}`,
};
}
},
},
};
}

View file

@ -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;
};
};

View file

@ -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],
);
}
}

View file

@ -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,

View file

@ -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 = {

View file

@ -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

View file

@ -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;

View file

@ -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