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