Replace agent handoff system with planning-based router (#16003)

## Overview

This PR replaces the dynamic agent handoff system with a more
predictable planning-based router that decides upfront how to handle
multi-agent coordination.

## Major Changes

### 🔄 Architecture Shift: Handoffs → Planning

**Removed:**
- `AgentHandoffEntity` and handoff tracking system
- `AgentHandoffService` and `AgentHandoffExecutorService`
- Dynamic agent-to-agent transfers during execution
- Handoff tool generation and description templates

**Added:**
- `AiRouterService` with two strategies: `simple` (single agent) and
`planned` (multi-agent)
- `AgentPlanExecutorService` for executing multi-step plans
- Plan validation (cycle detection, dependency resolution)
- `UnifiedRouterResult` type with discriminated union

### 🤖 New Standard Agents

Added two new specialized agents:
- **Researcher Agent**: Web search, fact-finding, competitive
intelligence
- **Code Agent**: TypeScript function generation for serverless
workflows

### 🏗️ Router Refactoring (Latest)

Split router responsibilities into focused services:
- `AiRouterStrategyDeciderService`: Decides simple vs planned strategy
- `AiRouterPlanGeneratorService`: Generates and validates execution
plans
- `AiRouterService`: Coordinates between services (reduced from 426→275
lines)

### ⚙️ Configuration Improvements

- Added `outputStrategy` to agent definitions (`direct` vs `synthesize`)
- Removed hardcoded special cases for workflow-builder
- Added `plannerModel` field to workspace entity
- Increased `MAX_STEPS` from 10 to 25 for complex workflows

### 📝 Agent Prompt Refinements

Significantly simplified prompts for better clarity:
- Workflow Builder: 51→36 lines
- Helper: 49→28 lines
- Data Manipulator: Enhanced with sorting guidance

### 🔍 Enhanced Debugging

- Plan reasoning and step count in data message parts
- Router debug info with token usage tracking
- Better logging throughout execution pipeline

## Benefits

1. **Simpler Mental Model**: Router decides upfront vs dynamic transfers
2. **Better Predictability**: Users see the plan before execution
3. **Cleaner Architecture**: SRP with focused services
4. **Configuration Over Code**: Agent behavior via config, not hardcoded
logic
5. **Plan Validation**: Catches invalid dependencies and cycles

## Migration Notes

- Database migration removes `agentHandoff` table
- Adds `plannerModel` column to workspace table
- No API breaking changes (agent endpoints unchanged)

## Testing

- Integration tests updated to remove handoff dependencies
- Agent tool test utilities simplified
- Plan validation covered by new logic

## Next Steps (Future PRs)

- Parallel execution of independent plan steps
- Dynamic re-planning based on results
- Plan caching for common routing patterns
- Error recovery strategies in plan executor
This commit is contained in:
Félix Malfait
2025-11-25 12:10:14 +01:00
committed by GitHub
parent 3c0ae49a23
commit e7ebf51e50
177 changed files with 2720 additions and 3222 deletions
File diff suppressed because one or more lines are too long
+4 -57
View File
@@ -113,13 +113,6 @@ export type AgentChatThread = {
updatedAt: Scalars['DateTime'];
};
export type AgentHandoff = {
__typename?: 'AgentHandoff';
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
toAgent: Agent;
};
export type AgentIdInput = {
/** The id of the agent. */
id: Scalars['UUID'];
@@ -755,12 +748,6 @@ export type CoreViewSort = {
workspaceId: Scalars['UUID'];
};
export type CreateAgentHandoffInput = {
description?: InputMaybe<Scalars['String']>;
fromAgentId: Scalars['UUID'];
toAgentId: Scalars['UUID'];
};
export type CreateAgentInput = {
description?: InputMaybe<Scalars['String']>;
icon?: InputMaybe<Scalars['String']>;
@@ -1730,10 +1717,8 @@ export type Mutation = {
checkPublicDomainValidRecords?: Maybe<DomainValidRecords>;
checkoutSession: BillingSessionOutput;
computeStepOutputSchema: Scalars['JSON'];
createAgentHandoff: Scalars['Boolean'];
createApiKey: ApiKey;
createApprovedAccessDomain: ApprovedAccessDomain;
createChatThread: AgentChatThread;
createCoreView: CoreView;
createCoreViewField: CoreViewField;
createCoreViewFilter: CoreViewFilter;
@@ -1827,7 +1812,6 @@ export type Mutation = {
initiateOTPProvisioning: InitiateTwoFactorAuthenticationProvisioningOutput;
initiateOTPProvisioningForAuthenticatedUser: InitiateTwoFactorAuthenticationProvisioningOutput;
publishServerlessFunction: ServerlessFunction;
removeAgentHandoff: Scalars['Boolean'];
removeRoleFromAgent: Scalars['Boolean'];
renewToken: AuthTokens;
resendEmailVerificationToken: ResendEmailVerificationTokenOutput;
@@ -1950,11 +1934,6 @@ export type MutationComputeStepOutputSchemaArgs = {
};
export type MutationCreateAgentHandoffArgs = {
input: CreateAgentHandoffInput;
};
export type MutationCreateApiKeyArgs = {
input: CreateApiKeyInput;
};
@@ -2410,11 +2389,6 @@ export type MutationPublishServerlessFunctionArgs = {
};
export type MutationRemoveAgentHandoffArgs = {
input: RemoveAgentHandoffInput;
};
export type MutationRemoveRoleFromAgentArgs = {
agentId: Scalars['UUID'];
};
@@ -3132,17 +3106,12 @@ export type Query = {
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
billingPortalSession: BillingSessionOutput;
chatMessages: Array<AgentChatMessage>;
chatThread: AgentChatThread;
chatThreads: Array<AgentChatThread>;
checkUserExists: CheckUserExistOutput;
checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValidOutput;
currentUser: User;
currentWorkspace: Workspace;
field: Field;
fields: FieldConnection;
findAgentHandoffTargets: Array<Agent>;
findAgentHandoffs: Array<AgentHandoff>;
findManyAgents: Array<Agent>;
findManyApplications: Array<Application>;
findManyCronTriggers: Array<CronTrigger>;
@@ -3223,16 +3192,6 @@ export type QueryBillingPortalSessionArgs = {
};
export type QueryChatMessagesArgs = {
threadId: Scalars['UUID'];
};
export type QueryChatThreadArgs = {
id: Scalars['UUID'];
};
export type QueryCheckUserExistsArgs = {
captchaToken?: InputMaybe<Scalars['String']>;
email: Scalars['String'];
@@ -3244,16 +3203,6 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = {
};
export type QueryFindAgentHandoffTargetsArgs = {
input: AgentIdInput;
};
export type QueryFindAgentHandoffsArgs = {
input: AgentIdInput;
};
export type QueryFindOneAgentArgs = {
input: AgentIdInput;
};
@@ -3603,11 +3552,6 @@ export enum RemoteTableStatus {
SYNCED = 'SYNCED'
}
export type RemoveAgentHandoffInput = {
fromAgentId: Scalars['UUID'];
toAgentId: Scalars['UUID'];
};
export type ResendEmailVerificationTokenOutput = {
__typename?: 'ResendEmailVerificationTokenOutput';
success: Scalars['Boolean'];
@@ -4324,6 +4268,7 @@ export type UpdateWorkspaceInput = {
defaultRoleId?: InputMaybe<Scalars['UUID']>;
displayName?: InputMaybe<Scalars['String']>;
editableProfileFields?: InputMaybe<Array<Scalars['String']>>;
fastModel?: InputMaybe<Scalars['String']>;
inviteHash?: InputMaybe<Scalars['String']>;
isGoogleAuthBypassEnabled?: InputMaybe<Scalars['Boolean']>;
isGoogleAuthEnabled?: InputMaybe<Scalars['Boolean']>;
@@ -4334,7 +4279,7 @@ export type UpdateWorkspaceInput = {
isPublicInviteLinkEnabled?: InputMaybe<Scalars['Boolean']>;
isTwoFactorAuthenticationEnforced?: InputMaybe<Scalars['Boolean']>;
logo?: InputMaybe<Scalars['String']>;
routerModel?: InputMaybe<Scalars['String']>;
smartModel?: InputMaybe<Scalars['String']>;
subdomain?: InputMaybe<Scalars['String']>;
trashRetentionDays?: InputMaybe<Scalars['Float']>;
};
@@ -4661,6 +4606,7 @@ export type Workspace = {
deletedAt?: Maybe<Scalars['DateTime']>;
displayName?: Maybe<Scalars['String']>;
editableProfileFields?: Maybe<Array<Scalars['String']>>;
fastModel: Scalars['String'];
featureFlags?: Maybe<Array<FeatureFlagDto>>;
hasValidEnterpriseKey: Scalars['Boolean'];
id: Scalars['UUID'];
@@ -4677,6 +4623,7 @@ export type Workspace = {
logo?: Maybe<Scalars['String']>;
metadataVersion: Scalars['Float'];
routerModel: Scalars['String'];
smartModel: Scalars['String'];
subdomain: Scalars['String'];
trashRetentionDays: Scalars['Float'];
updatedAt: Scalars['DateTime'];
@@ -246,7 +246,8 @@ const DetailsTab = ({ debug, copyToClipboard }: DetailsTabProps) => {
id: debug.selectedAgentId,
label: debug.selectedAgentLabel,
},
routerModel: debug.routerModel,
fastModel: debug.fastModel,
smartModel: debug.smartModel,
agentModel: debug.agentModel,
availableAgents: debug.availableAgents,
};
@@ -1,47 +1,57 @@
import { RoutingDebugDisplay } from '@/ai/components/RoutingDebugDisplay';
import { ShimmeringText } from '@/ai/components/ShimmeringText';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useState } from 'react';
import { type DataMessagePart } from 'twenty-shared/ai';
import { IconCpu, IconSparkles } from 'twenty-ui/display';
import { IconChevronDown, IconChevronUp, IconCpu } from 'twenty-ui/display';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
const StyledRoutingContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.lighter};
border: ${({ theme }) => `1px dashed ${theme.border.color.medium}`};
border-radius: ${({ theme }) => theme.border.radius.md};
const StyledContainer = styled.div`
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2, 3)};
width: fit-content;
`;
const StyledIconContainer = styled.div<{ isLoading: boolean }>`
const StyledToggleButton = styled.div<{ isExpandable: boolean }>`
align-items: center;
animation: ${({ isLoading }) =>
isLoading ? 'pulseAnimation 2s ease-in-out infinite' : 'none'};
color: ${({ theme }) => theme.color.blue};
background: none;
border: none;
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'auto')};
display: flex;
color: ${({ theme }) => theme.font.color.tertiary};
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(1)} 0;
transition: color ${({ theme }) => theme.animation.duration.normal}s;
@keyframes pulseAnimation {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
&:hover {
color: ${({ isExpandable, theme }) =>
isExpandable ? theme.font.color.secondary : theme.font.color.tertiary};
}
`;
const StyledText = styled.div`
const StyledDisplayMessage = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StyledWrapper = styled.div`
const StyledIconTextContainer = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: ${({ theme }) => theme.spacing(1)};
svg {
min-width: ${({ theme }) => theme.icon.size.sm}px;
}
`;
const StyledContentContainer = styled.div`
background: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
min-width: 0;
padding: ${({ theme }) => theme.spacing(3)};
`;
export const RoutingStatusDisplay = ({
@@ -49,28 +59,54 @@ export const RoutingStatusDisplay = ({
}: {
data: DataMessagePart['routing-status'];
}) => {
const theme = useTheme();
const [isExpanded, setIsExpanded] = useState(false);
const isLoading = data.state === 'loading';
const isDebugMode = process.env.IS_DEBUG_MODE === 'true';
const isExpandable = isDebugMode && data.state === 'routed' && data.debug;
if (data.state === 'error') {
return null;
}
if (isLoading) {
return (
<StyledContainer>
<StyledIconTextContainer>
<IconCpu size={theme.icon.size.sm} />
<ShimmeringText>
<StyledDisplayMessage>{data.text}</StyledDisplayMessage>
</ShimmeringText>
</StyledIconTextContainer>
</StyledContainer>
);
}
return (
<StyledWrapper>
<StyledRoutingContainer>
<StyledIconContainer isLoading={isLoading}>
{isLoading ? <IconSparkles size={16} /> : <IconCpu size={16} />}
</StyledIconContainer>
{isLoading ? (
<ShimmeringText>{data.text}</ShimmeringText>
) : (
<StyledText>{data.text}</StyledText>
)}
</StyledRoutingContainer>
{isDebugMode && data.state === 'routed' && data.debug && (
<RoutingDebugDisplay debug={data.debug} />
<StyledContainer>
<StyledToggleButton
onClick={() => isExpandable && setIsExpanded(!isExpanded)}
isExpandable={!!isExpandable}
>
<StyledIconTextContainer>
<IconCpu size={theme.icon.size.sm} />
<StyledDisplayMessage>{data.text}</StyledDisplayMessage>
</StyledIconTextContainer>
{isExpandable &&
(isExpanded ? (
<IconChevronUp size={theme.icon.size.sm} />
) : (
<IconChevronDown size={theme.icon.size.sm} />
))}
</StyledToggleButton>
{isExpandable && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledContentContainer>
<RoutingDebugDisplay debug={data.debug!} />
</StyledContentContainer>
</AnimatedExpandableContainer>
)}
</StyledWrapper>
</StyledContainer>
);
};
@@ -0,0 +1 @@
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
@@ -0,0 +1 @@
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
@@ -1,7 +0,0 @@
import { gql } from '@apollo/client';
export const CREATE_AGENT_HANDOFF = gql`
mutation CreateAgentHandoff($input: CreateAgentHandoffInput!) {
createAgentHandoff(input: $input)
}
`;
@@ -1,7 +0,0 @@
import { gql } from '@apollo/client';
export const REMOVE_AGENT_HANDOFF = gql`
mutation RemoveAgentHandoff($input: RemoveAgentHandoffInput!) {
removeAgentHandoff(input: $input)
}
`;
@@ -1,18 +0,0 @@
import { gql } from '@apollo/client';
export const FIND_AGENT_HANDOFF_TARGETS = gql`
query FindAgentHandoffTargets($input: AgentIdInput!) {
findAgentHandoffTargets(input: $input) {
id
name
label
description
icon
modelId
prompt
isCustom
createdAt
updatedAt
}
}
`;
@@ -1,22 +0,0 @@
import { gql } from '@apollo/client';
export const FIND_AGENT_HANDOFFS = gql`
query FindAgentHandoffs($input: AgentIdInput!) {
findAgentHandoffs(input: $input) {
id
description
toAgent {
id
name
label
description
icon
modelId
prompt
isCustom
createdAt
updatedAt
}
}
}
`;
@@ -2,13 +2,20 @@ import { aiModelsState } from '@/client-config/states/aiModelsState';
import { useRecoilValue } from 'recoil';
import { type SelectOption } from 'twenty-ui/input';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
export const useAiModelOptions = (): SelectOption<string>[] => {
const aiModels = useRecoilValue(aiModelsState);
return aiModels
.map((model) => ({
value: model.modelId,
label: `${model.label} (${model.provider})`,
label:
model.modelId === DEFAULT_FAST_MODEL ||
model.modelId === DEFAULT_SMART_MODEL
? model.label
: `${model.label} (${model.provider})`,
}))
.sort((a, b) => a.label.localeCompare(b.label));
};
@@ -1,6 +1,8 @@
import { ApolloError, gql, InMemoryCache } from '@apollo/client';
import fetchMock, { enableFetchMocks } from 'jest-fetch-mock';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { WorkspaceActivationStatus } from '~/generated/graphql';
import { ApolloFactory, type Options } from '../apollo.factory';
@@ -62,6 +64,8 @@ const mockWorkspace = {
},
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
workspaceCustomApplication: CUSTOM_WORKSPACE_APPLICATION_MOCK,
workspaceCustomApplicationId: CUSTOM_WORKSPACE_APPLICATION_MOCK.id,
@@ -32,7 +32,8 @@ export type CurrentWorkspace = Pick<
| 'metadataVersion'
| 'isTwoFactorAuthenticationEnforced'
| 'trashRetentionDays'
| 'routerModel'
| 'fastModel'
| 'smartModel'
| 'editableProfileFields'
> & {
defaultRole?: Omit<Role, 'workspaceMembers' | 'agents' | 'apiKeys'> | null;
@@ -4,6 +4,8 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { useColumnDefinitionsFromObjectMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromObjectMetadata';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import {
SubscriptionInterval,
SubscriptionStatus,
@@ -58,7 +60,8 @@ const Wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
],
isTwoFactorAuthenticationEnforced: false,
trashRetentionDays: 14,
routerModel: 'auto',
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
});
},
});
@@ -82,7 +82,8 @@ export const USER_QUERY_FRAGMENT = gql`
defaultRole {
...RoleFragment
}
routerModel
fastModel
smartModel
isTwoFactorAuthenticationEnforced
trashRetentionDays
editableProfileFields
@@ -2,6 +2,8 @@ import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import {
StyledSettingsOptionCardContent,
@@ -13,7 +15,7 @@ import { SettingsOptionIconCustomizer } from '@/settings/components/SettingsOpti
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { t } from '@lingui/core/macro';
import { H2Title, IconCpu } from 'twenty-ui/display';
import { H2Title, IconBolt, IconBrain } from 'twenty-ui/display';
import { Card, Section } from 'twenty-ui/layout';
import { useUpdateWorkspaceMutation } from '~/generated-metadata/graphql';
@@ -39,39 +41,76 @@ export const SettingsAIRouterSettings = () => {
const modelOptions = useAiModelOptions();
const noModelsAvailable = modelOptions.length === 0;
const handleModelChange = async (value: string) => {
const handleFastModelChange = async (value: string) => {
if (!currentWorkspace?.id) {
return;
}
const newValue = value;
const previousValue = currentWorkspace?.routerModel || 'auto';
const previousValue = currentWorkspace?.fastModel || DEFAULT_FAST_MODEL;
try {
setCurrentWorkspace({
...currentWorkspace,
routerModel: newValue,
fastModel: newValue,
});
await updateWorkspace({
variables: {
input: {
routerModel: newValue,
fastModel: newValue,
},
},
});
enqueueSuccessSnackBar({
message: t`Router model updated successfully`,
message: t`Fast model updated successfully`,
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
routerModel: previousValue,
fastModel: previousValue,
});
enqueueErrorSnackBar({
message: t`Failed to update router model`,
message: t`Failed to update fast model`,
});
}
};
const handleSmartModelChange = async (value: string) => {
if (!currentWorkspace?.id) {
return;
}
const newValue = value;
const previousValue = currentWorkspace?.smartModel || DEFAULT_SMART_MODEL;
try {
setCurrentWorkspace({
...currentWorkspace,
smartModel: newValue,
});
await updateWorkspace({
variables: {
input: {
smartModel: newValue,
},
},
});
enqueueSuccessSnackBar({
message: t`Smart model updated successfully`,
});
} catch {
setCurrentWorkspace({
...currentWorkspace,
smartModel: previousValue,
});
enqueueErrorSnackBar({
message: t`Failed to update smart model`,
});
}
};
@@ -79,40 +118,67 @@ export const SettingsAIRouterSettings = () => {
return (
<Section>
<H2Title
title={t`Router`}
description={t`Router is used in the chat to dispatch to the right agent`}
title={t`AI Models`}
description={t`Configure AI models for routing and planning`}
/>
<Card rounded>
<StyledSettingsOptionCardContent>
<StyledSettingsOptionCardIcon>
<SettingsOptionIconCustomizer Icon={IconCpu} />
</StyledSettingsOptionCardIcon>
<div>
<StyledSettingsOptionCardTitle>
{t`Router Model`}
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
{t`Fast model to route to the right agent`}
</StyledSettingsOptionCardDescription>
</div>
<StyledSelectContainer>
{noModelsAvailable ? (
<StyledErrorMessage>
{t`No models available. Please configure AI models in your workspace settings.`}
</StyledErrorMessage>
) : (
{noModelsAvailable ? (
<Card rounded>
<StyledSettingsOptionCardContent>
<StyledErrorMessage>
{t`No models available. Please configure AI models in your workspace settings.`}
</StyledErrorMessage>
</StyledSettingsOptionCardContent>
</Card>
) : (
<Card rounded>
<StyledSettingsOptionCardContent>
<StyledSettingsOptionCardIcon>
<SettingsOptionIconCustomizer Icon={IconBolt} />
</StyledSettingsOptionCardIcon>
<div>
<StyledSettingsOptionCardTitle>
{t`Fast Model`}
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
{t`Quick model for routing decisions`}
</StyledSettingsOptionCardDescription>
</div>
<StyledSelectContainer>
<Select
dropdownId="router-model-select"
value={currentWorkspace?.routerModel || 'auto'}
onChange={handleModelChange}
dropdownId="fast-model-select"
value={currentWorkspace?.fastModel || DEFAULT_FAST_MODEL}
onChange={handleFastModelChange}
options={modelOptions}
selectSizeVariant="small"
/>
)}
</StyledSelectContainer>
</StyledSettingsOptionCardContent>
</Card>
</StyledSelectContainer>
</StyledSettingsOptionCardContent>
<StyledSettingsOptionCardContent>
<StyledSettingsOptionCardIcon>
<SettingsOptionIconCustomizer Icon={IconBrain} />
</StyledSettingsOptionCardIcon>
<div>
<StyledSettingsOptionCardTitle>
{t`Smart Model`}
</StyledSettingsOptionCardTitle>
<StyledSettingsOptionCardDescription>
{t`Advanced model for complex planning`}
</StyledSettingsOptionCardDescription>
</div>
<StyledSelectContainer>
<Select
dropdownId="smart-model-select"
value={currentWorkspace?.smartModel || DEFAULT_SMART_MODEL}
onChange={handleSmartModelChange}
options={modelOptions}
selectSizeVariant="small"
/>
</StyledSelectContainer>
</StyledSettingsOptionCardContent>
</Card>
)}
</Section>
);
};
@@ -1,151 +0,0 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useState } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { Select } from '@/ui/input/components/Select';
import { TextArea } from '@/ui/input/components/TextArea';
import { IconPlus } from 'twenty-ui/display';
import { Button, type SelectOption } from 'twenty-ui/input';
import { useCreateAgentHandoffMutation } from '~/generated-metadata/graphql';
const StyledAddHandoffForm = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
padding: ${({ theme }) => theme.spacing(3)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const StyledFormActions = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
justify-content: flex-end;
`;
const StyledAddButtonContainer = styled.div`
display: flex;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.spacing(3)};
`;
type SettingsAgentHandoffFormProps = {
agentId: string;
availableAgentOptions: SelectOption[];
agentsLoading: boolean;
onHandoffAdded: () => void;
};
export const SettingsAgentHandoffForm = ({
agentId,
availableAgentOptions,
agentsLoading,
onHandoffAdded,
}: SettingsAgentHandoffFormProps) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [isAddingHandoff, setIsAddingHandoff] = useState(false);
const [selectedTargetAgentId, setSelectedTargetAgentId] = useState('');
const [handoffDescription, setHandoffDescription] = useState('');
const [createAgentHandoff] = useCreateAgentHandoffMutation();
const noAvailableAgents = availableAgentOptions.length === 0;
const resetHandoffForm = () => {
setIsAddingHandoff(false);
setSelectedTargetAgentId('');
setHandoffDescription('');
};
const handleAddHandoff = async () => {
try {
await createAgentHandoff({
variables: {
input: {
fromAgentId: agentId,
toAgentId: selectedTargetAgentId,
description: handoffDescription,
},
},
});
onHandoffAdded();
resetHandoffForm();
enqueueSuccessSnackBar({
message: t`Handoff created successfully`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to create handoff`,
});
}
};
return (
<>
{isAddingHandoff ? (
<StyledAddHandoffForm>
<Select
fullWidth
dropdownId="handoff-target-select"
label={t`Target Agent`}
value={selectedTargetAgentId}
onChange={setSelectedTargetAgentId}
options={availableAgentOptions}
emptyOption={{
label: t`Select a target agent`,
value: '',
}}
/>
<TextArea
textAreaId="handoff-description-textarea"
label={t`Description (Optional)`}
placeholder={t`Describe when this handoff should be used`}
minRows={2}
value={handoffDescription}
onChange={setHandoffDescription}
/>
<StyledFormActions>
<Button
size="small"
variant="secondary"
title={t`Cancel`}
onClick={resetHandoffForm}
/>
<Button
accent="blue"
size="small"
title={t`Add Handoff`}
onClick={handleAddHandoff}
disabled={!selectedTargetAgentId}
/>
</StyledFormActions>
</StyledAddHandoffForm>
) : (
<StyledAddButtonContainer>
<Button
variant="secondary"
size="small"
title={
agentsLoading
? t`Loading...`
: noAvailableAgents
? t`No agents available for handoff`
: t`Add Handoff`
}
Icon={IconPlus}
onClick={() => setIsAddingHandoff(true)}
disabled={agentsLoading || noAvailableAgents}
/>
</StyledAddButtonContainer>
)}
</>
);
};
@@ -1,64 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { H2Title } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import {
useFindAgentHandoffsQuery,
useFindManyAgentsQuery,
} from '~/generated-metadata/graphql';
import { SettingsAgentHandoffForm } from './SettingsAgentHandoffForm';
import { SettingsAgentHandoffTable } from './SettingsAgentHandoffTable';
export const SettingsAgentHandoffSection = ({
agentId,
}: {
agentId: string;
}) => {
const { t } = useLingui();
const { data: agentsData, loading: agentsLoading } = useFindManyAgentsQuery();
const { data: handoffData, refetch: refetchHandoffTargets } =
useFindAgentHandoffsQuery({
variables: { input: { id: agentId } },
skip: !agentId,
});
const handoffTargets = handoffData?.findAgentHandoffs || [];
const availableAgentOptions =
agentsData?.findManyAgents?.reduce<SelectOption[]>((acc, agent) => {
if (
agent.id !== agentId &&
!handoffTargets.some((handoff: any) => handoff.toAgent.id === agent.id)
) {
acc.push({
label: agent.label,
value: agent.id,
});
}
return acc;
}, []) || [];
return (
<Section>
<H2Title
title={t`Agent Handoffs`}
description={t`Configure which agents this agent can hand off conversations to`}
/>
<SettingsAgentHandoffTable
agentId={agentId}
handoffTargets={handoffTargets}
onHandoffRemoved={refetchHandoffTargets}
/>
<SettingsAgentHandoffForm
agentId={agentId}
availableAgentOptions={availableAgentOptions}
agentsLoading={agentsLoading}
onHandoffAdded={refetchHandoffTargets}
/>
</Section>
);
};
@@ -1,186 +0,0 @@
import styled from '@emotion/styled';
import { Trans, useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { TextInput } from '@/ui/input/components/TextInput';
import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal';
import { useModal } from '@/ui/layout/modal/hooks/useModal';
import { Table } from '@/ui/layout/table/components/Table';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import {
IconSearch,
IconTrash,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useRemoveAgentHandoffMutation } from '~/generated-metadata/graphql';
import { type AgentHandoff } from '~/generated/graphql';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const AGENT_HANDOFF_DELETION_MODAL_ID = 'agent-handoff-deletion-modal';
const StyledButtonContainer = styled.div`
align-items: center;
display: flex;
flex-direction: row;
margin-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledTable = styled(Table)`
border-bottom: 1px solid ${({ theme }) => theme.border.color.light};
`;
const StyledSearchContainer = styled.div`
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledTableRows = styled.div`
padding-block: ${({ theme }) => theme.spacing(2)};
`;
const StyledNoHandoffs = styled(TableCell)`
color: ${({ theme }) => theme.font.color.tertiary};
`;
const StyledTableCell = styled(TableCell)`
overflow: hidden;
`;
type SettingsAgentHandoffTableProps = {
agentId: string;
handoffTargets: AgentHandoff[];
onHandoffRemoved: () => void;
};
export const SettingsAgentHandoffTable = ({
agentId,
handoffTargets,
onHandoffRemoved,
}: SettingsAgentHandoffTableProps) => {
const { t } = useLingui();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [searchFilter, setSearchFilter] = useState('');
const [handoffToDelete, setHandoffToDelete] = useState<string | undefined>();
const [removeAgentHandoff] = useRemoveAgentHandoffMutation();
const filteredHandoffTargets = useMemo(() => {
if (!searchFilter) return handoffTargets;
const searchTerm = normalizeSearchText(searchFilter);
return handoffTargets.filter((handoff) => {
const label = normalizeSearchText(handoff.toAgent.label);
const description = normalizeSearchText(handoff.description);
return label.includes(searchTerm) || description.includes(searchTerm);
});
}, [handoffTargets, searchFilter]);
const handleRemoveHandoff = async () => {
if (!handoffToDelete) {
return;
}
try {
await removeAgentHandoff({
variables: {
input: {
fromAgentId: agentId,
toAgentId: handoffToDelete,
},
},
});
onHandoffRemoved();
enqueueSuccessSnackBar({
message: t`Handoff removed successfully`,
});
} catch {
enqueueErrorSnackBar({
message: t`Failed to remove handoff`,
});
}
};
const { openModal } = useModal();
return (
<>
<StyledSearchContainer>
<TextInput
value={searchFilter}
onChange={setSearchFilter}
placeholder={t`Search handoffs...`}
fullWidth
LeftIcon={IconSearch}
sizeVariant="lg"
/>
</StyledSearchContainer>
<StyledTable>
<TableRow gridAutoColumns="2fr 2fr 1fr">
<TableHeader>
<Trans>Target Agent</Trans>
</TableHeader>
<TableHeader>
<Trans>Description</Trans>
</TableHeader>
<TableHeader align="right" />
</TableRow>
<StyledTableRows>
{filteredHandoffTargets.length > 0 ? (
filteredHandoffTargets.map((handoff) => (
<TableRow gridAutoColumns="2fr 2fr 1fr" key={handoff.id}>
<TableCell>
<OverflowingTextWithTooltip text={handoff.toAgent.label} />
</TableCell>
<StyledTableCell>
<OverflowingTextWithTooltip
text={handoff.description || t`No description`}
/>
</StyledTableCell>
<TableCell align="right">
<StyledButtonContainer>
<IconButton
onClick={() => {
openModal(AGENT_HANDOFF_DELETION_MODAL_ID);
setHandoffToDelete(handoff.toAgent.id);
}}
variant="tertiary"
size="medium"
Icon={IconTrash}
/>
</StyledButtonContainer>
</TableCell>
</TableRow>
))
) : (
<StyledNoHandoffs>
{!searchFilter
? t`No handoffs configured for this agent`
: t`No handoffs match your search`}
</StyledNoHandoffs>
)}
</StyledTableRows>
</StyledTable>
<ConfirmationModal
modalId={AGENT_HANDOFF_DELETION_MODAL_ID}
title={t`Remove Handoff`}
subtitle={
<Trans>
This action cannot be undone. This will permanently remove the
handoff configuration.
</Trans>
}
onConfirmClick={handleRemoveHandoff}
confirmButtonText={t`Remove handoff`}
/>
</>
);
};
@@ -1,3 +1,5 @@
import { DEFAULT_FAST_MODEL } from '@/ai/constants/DefaultFastModel';
import { DEFAULT_SMART_MODEL } from '@/ai/constants/DefaultSmartModel';
import { type CurrentUserWorkspace } from '@/auth/states/currentUserWorkspaceState';
import { CUSTOM_WORKSPACE_APPLICATION_MOCK } from '@/object-metadata/hooks/__tests__/constants/CustomWorkspaceApplicationMock.test.constant';
import { type WorkspaceMember } from '@/workspace-member/types/WorkspaceMember';
@@ -92,6 +94,8 @@ export const mockCurrentWorkspace = {
updatedAt: '2023-04-26T10:23:42.33625+00:00',
metadataVersion: 1,
trashRetentionDays: 14,
fastModel: DEFAULT_FAST_MODEL,
smartModel: DEFAULT_SMART_MODEL,
routerModel: 'auto',
currentBillingSubscription: {
__typename: 'BillingSubscription',
@@ -9,7 +9,7 @@ import {
type RunOnWorkspaceArgs,
} from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SEARCH_FIELDS_FOR_CUSTOM_OBJECT } from 'src/engine/twenty-orm/custom.workspace-entity';
@@ -0,0 +1,36 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class RemoveAgentHandoffTable1763805513241
implements MigrationInterface
{
name = 'RemoveAgentHandoffTable1763805513241';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP TABLE IF EXISTS "core"."agentHandoff" CASCADE`,
);
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "responseFormat" SET DEFAULT '{"type":"text"}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "responseFormat" DROP DEFAULT`,
);
await queryRunner.query(`CREATE TABLE "core"."agentHandoff" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"fromAgentId" uuid NOT NULL,
"toAgentId" uuid NOT NULL,
"workspaceId" uuid NOT NULL,
"description" text,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_agentHandoff" PRIMARY KEY ("id"),
CONSTRAINT "FK_agentHandoff_fromAgent" FOREIGN KEY ("fromAgentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE,
CONSTRAINT "FK_agentHandoff_toAgent" FOREIGN KEY ("toAgentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE,
CONSTRAINT "FK_agentHandoff_workspace" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE
)`);
}
}
@@ -0,0 +1,30 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
import {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
export class AddFastAndSmartModelsToWorkspace1763997530458
implements MigrationInterface
{
name = 'AddFastAndSmartModelsToWorkspace1763997530458';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "fastModel" character varying NOT NULL DEFAULT '${DEFAULT_FAST_MODEL}'`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" ADD "smartModel" character varying NOT NULL DEFAULT '${DEFAULT_SMART_MODEL}'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "smartModel"`,
);
await queryRunner.query(
`ALTER TABLE "core"."workspace" DROP COLUMN "fastModel"`,
);
}
}
@@ -0,0 +1,17 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class CoreMigrationCheck1764066845539 implements MigrationInterface {
name = 'CoreMigrationCheck1764066845539';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "modelId" SET DEFAULT 'default-smart-model'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" ALTER COLUMN "modelId" SET DEFAULT 'auto'`,
);
}
}
@@ -1,30 +1,29 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
import { McpController } from './mcp.controller';
describe('McpController', () => {
let controller: McpController;
let mcpService: jest.Mocked<McpService>;
describe('McpCoreController', () => {
let controller: McpCoreController;
let mcpProtocolService: jest.Mocked<McpProtocolService>;
beforeEach(async () => {
const mockMcpService = {
const mockMcpProtocolService = {
handleMCPCoreQuery: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [McpController],
controllers: [McpCoreController],
providers: [
{
provide: McpService,
useValue: mockMcpService,
provide: McpProtocolService,
useValue: mockMcpProtocolService,
},
{
provide: AccessTokenService,
@@ -43,8 +42,8 @@ describe('McpController', () => {
],
}).compile();
controller = module.get<McpController>(McpController);
mcpService = module.get(McpService);
controller = module.get<McpCoreController>(McpCoreController);
mcpProtocolService = module.get(McpProtocolService);
});
it('should be defined', () => {
@@ -56,7 +55,7 @@ describe('McpController', () => {
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = 'api-key-1';
it('should call mcpService.handleMCPCoreQuery with correct parameters', async () => {
it('should call mcpProtocolService.handleMCPCoreQuery with correct parameters', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
@@ -73,7 +72,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -82,11 +81,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
@@ -110,7 +112,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -119,11 +121,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
@@ -152,7 +157,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -161,11 +166,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
});
@@ -8,9 +8,9 @@ import {
ValidationPipe,
} from '@nestjs/common';
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
@@ -22,8 +22,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('mcp')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(RestApiExceptionFilter)
export class McpController {
constructor(private readonly mcpService: McpService) {}
export class McpCoreController {
constructor(private readonly mcpProtocolService: McpProtocolService) {}
@Post()
@UsePipes(
@@ -39,7 +39,7 @@ export class McpController {
@AuthApiKey() apiKey: string | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
return await this.mcpService.handleMCPCoreQuery(body, {
return await this.mcpProtocolService.handleMCPCoreQuery(body, {
workspace,
userWorkspaceId,
apiKey,
@@ -8,7 +8,7 @@ import {
Validate,
} from 'class-validator';
import { IsNumberOrString } from 'src/engine/core-modules/ai/decorators/string-or-number';
import { IsNumberOrString } from 'src/engine/api/mcp/decorators/string-or-number';
export class JsonRpc {
@IsString()
@@ -1,36 +1,43 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
import { McpMetadataController } from 'src/engine/api/mcp/controllers/mcp-metadata.controller';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.tools.service';
import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service';
import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service';
import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity]),
AiModule,
AiToolsModule,
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
RestApiModule,
MetadataQueryBuilderModule,
MetricsModule,
UserRoleModule,
],
controllers: [McpMetadataController],
exports: [],
controllers: [McpCoreController, McpMetadataController],
exports: [McpProtocolService],
providers: [
McpProtocolService,
McpToolExecutorService,
MCPMetadataService,
MCPMetadataToolsService,
CreateToolsService,
@@ -4,10 +4,11 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { jsonSchema } from 'ai';
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -15,11 +16,12 @@ import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/consta
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
describe('McpService', () => {
let service: McpService;
describe('McpProtocolService', () => {
let service: McpProtocolService;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let toolService: jest.Mocked<ToolService>;
let userRoleService: jest.Mocked<UserRoleService>;
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockUserWorkspaceId = 'user-workspace-1';
@@ -40,6 +42,11 @@ describe('McpService', () => {
getRoleIdForUserWorkspace: jest.fn(),
};
const mockMcpToolExecutorService = {
handleToolCall: jest.fn(),
handleToolsListing: jest.fn(),
};
const mockAdminRole = {
id: mockAdminRoleId,
label: ADMIN_ROLE_LABEL,
@@ -47,7 +54,7 @@ describe('McpService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
McpService,
McpProtocolService,
{
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
@@ -60,6 +67,10 @@ describe('McpService', () => {
provide: UserRoleService,
useValue: mockUserRoleService,
},
{
provide: McpToolExecutorService,
useValue: mockMcpToolExecutorService,
},
{
provide: getRepositoryToken(RoleEntity),
useValue: {
@@ -69,10 +80,11 @@ describe('McpService', () => {
],
}).compile();
service = module.get<McpService>(McpService);
service = module.get<McpProtocolService>(McpProtocolService);
featureFlagService = module.get(FeatureFlagService);
toolService = module.get(ToolService);
userRoleService = module.get(UserRoleService);
mcpToolExecutorService = module.get(McpToolExecutorService);
});
it('should be defined', () => {
@@ -209,19 +221,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toEqual({
const mockToolCallResponse = {
id: '123',
jsonrpc: '2.0',
result: {
@@ -234,11 +234,29 @@ describe('McpService', () => {
],
isError: false,
},
};
mcpToolExecutorService.handleToolCall.mockResolvedValue(
mockToolCallResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(mockTool.execute).toHaveBeenCalledWith(
{ arg1: 'value1' },
{ toolCallId: '1', messages: [] },
expect(result).toEqual(mockToolCallResponse);
expect(mcpToolExecutorService.handleToolCall).toHaveBeenCalledWith(
'123',
mockToolsMap,
{ name: 'testTool', arguments: { arg1: 'value1' } },
);
});
@@ -257,19 +275,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
apiKey: mockApiKey,
});
expect(result).toEqual({
const mockToolCallResponse = {
id: '123',
jsonrpc: '2.0',
result: {
@@ -282,16 +288,29 @@ describe('McpService', () => {
],
isError: false,
},
};
mcpToolExecutorService.handleToolCall.mockResolvedValue(
mockToolCallResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
apiKey: mockApiKey,
});
expect(result).toEqual(mockToolCallResponse);
expect(toolService.listTools).toHaveBeenCalledWith(
{ unionOf: [mockAdminRoleId] },
mockWorkspace.id,
);
expect(mockTool.execute).toHaveBeenCalledWith(
{ arg1: 'value1' },
{ toolCallId: '1', messages: [] },
);
});
it('should handle tools listing', async () => {
@@ -307,18 +326,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toMatchObject({
const mockToolsListingResponse = {
id: '123',
jsonrpc: '2.0',
result: expect.objectContaining({
@@ -334,7 +342,24 @@ describe('McpService', () => {
},
],
}),
};
mcpToolExecutorService.handleToolsListing.mockReturnValue(
mockToolsListingResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toMatchObject(mockToolsListingResponse);
});
it('should handle error when AI is disabled', async () => {
@@ -367,6 +392,13 @@ describe('McpService', () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
toolService.listTools.mockResolvedValue({});
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
"Tool 'nonExistentTool' not found",
HttpStatus.NOT_FOUND,
),
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
@@ -8,7 +8,7 @@ import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.too
import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service';
import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
@@ -1,13 +1,12 @@
import { HttpException, HttpStatus, Injectable } 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 JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -16,11 +15,12 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@Injectable()
export class McpService {
export class McpProtocolService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolService: ToolService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
) {}
@@ -134,11 +134,15 @@ export class McpService {
);
if (method === 'tools/call' && params) {
return await this.handleToolCall(id, toolSet, params);
return await this.mcpToolExecutorService.handleToolCall(
id,
toolSet,
params,
);
}
if (method === 'tools/list') {
return await this.handleToolsListing(id, toolSet);
return this.mcpToolExecutorService.handleToolsListing(id, toolSet);
}
if (method === 'prompts/list') {
@@ -175,70 +179,4 @@ export class McpService {
});
}
}
private async handleToolCall(
id: string | number,
toolSet: ToolSet,
params: Record<string, unknown>,
) {
const toolName = params.name as keyof typeof toolSet;
const tool = toolSet[toolName];
if (isDefined(tool) && isDefined(tool.execute)) {
return wrapJsonRpcResponse(id, {
result: {
content: [
{
type: 'text',
text: JSON.stringify(
await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
}),
),
},
],
isError: false,
},
});
}
throw new HttpException(
`Tool '${params.name}' not found`,
HttpStatus.NOT_FOUND,
);
}
private handleToolsListing(id: string | number, toolSet: ToolSet) {
const toolsArray = Object.entries(toolSet)
.filter(([, def]) => !!def.inputSchema)
.map(([name, def]) => {
// Unwrap the AI SDK's jsonSchema wrapper if present
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
const inputSchema = def.inputSchema;
const unwrappedSchema =
inputSchema &&
typeof inputSchema === 'object' &&
'jsonSchema' in inputSchema
? inputSchema.jsonSchema
: inputSchema;
return {
name,
description: def.description,
inputSchema: unwrappedSchema,
};
});
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
tools: { listChanged: false },
},
tools: toolsArray,
resources: [],
prompts: [],
},
});
}
}
@@ -0,0 +1,75 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
@Injectable()
export class McpToolExecutorService {
async handleToolCall(
id: string | number,
toolSet: ToolSet,
params: Record<string, unknown>,
) {
const toolName = params.name as keyof typeof toolSet;
const tool = toolSet[toolName];
if (isDefined(tool) && isDefined(tool.execute)) {
return wrapJsonRpcResponse(id, {
result: {
content: [
{
type: 'text',
text: JSON.stringify(
await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
}),
),
},
],
isError: false,
},
});
}
throw new HttpException(
`Tool '${params.name}' not found`,
HttpStatus.NOT_FOUND,
);
}
handleToolsListing(id: string | number, toolSet: ToolSet) {
const toolsArray = Object.entries(toolSet)
.filter(([, def]) => !!def.inputSchema)
.map(([name, def]) => {
// Unwrap the AI SDK's jsonSchema wrapper if present
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
const inputSchema = def.inputSchema;
const unwrappedSchema =
inputSchema &&
typeof inputSchema === 'object' &&
'jsonSchema' in inputSchema
? inputSchema.jsonSchema
: inputSchema;
return {
name,
description: def.description,
inputSchema: unwrappedSchema,
};
});
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
tools: { listChanged: false },
},
tools: toolsArray,
resources: [],
prompts: [],
},
});
}
}
@@ -1,4 +1,4 @@
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
export const wrapJsonRpcResponse = (
id: string | number,
@@ -1,190 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { openai } from '@ai-sdk/openai';
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { AiController } from './ai.controller';
describe('AiController', () => {
let controller: AiController;
let aiService: jest.Mocked<AiService>;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let aiBillingService: jest.Mocked<AIBillingService>;
let aiModelRegistryService: jest.Mocked<AiModelRegistryService>;
beforeEach(async () => {
const mockAiService = {
streamText: jest.fn(),
getModel: jest.fn(),
};
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
};
const mockAIBillingService = {
calculateAndBillUsage: jest.fn(),
};
const mockAiModelRegistryService = {
getDefaultPerformanceModel: jest.fn().mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: openai('gpt-4o'),
}),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
{
provide: AiService,
useValue: mockAiService,
},
{
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
},
{
provide: AIBillingService,
useValue: mockAIBillingService,
},
{
provide: AiModelRegistryService,
useValue: mockAiModelRegistryService,
},
],
}).compile();
controller = module.get<AiController>(AiController);
aiService = module.get(AiService);
featureFlagService = module.get(FeatureFlagService);
aiBillingService = module.get(AIBillingService);
aiModelRegistryService = module.get(AiModelRegistryService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('chat', () => {
const mockWorkspace = { id: 'workspace-1' } as any;
it('should handle valid chat request and bill usage', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
temperature: 0.7,
maxOutputTokens: 100,
};
const mockRes = {
setHeader: jest.fn(),
write: jest.fn(),
end: jest.fn(),
} as any;
const mockModel = openai('gpt-4o');
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: mockModel,
});
const mockUsage = {
inputTokens: 10,
outputTokens: 20,
totalTokens: 30,
};
const mockStreamTextResult = {
usage: Promise.resolve(mockUsage),
pipeUIMessageStreamToResponse: jest.fn(),
};
aiService.streamText.mockReturnValue(mockStreamTextResult as any);
await controller.chat(mockRequest, mockWorkspace, mockRes);
// Wait a microtask so the usage.then billing call fires
await Promise.resolve();
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalled();
expect(aiService.streamText).toHaveBeenCalledWith({
messages: mockRequest.messages,
options: {
temperature: 0.7,
maxOutputTokens: 100,
model: mockModel,
},
});
expect(
mockStreamTextResult.pipeUIMessageStreamToResponse,
).toHaveBeenCalledWith(mockRes);
expect(aiBillingService.calculateAndBillUsage).toHaveBeenCalledWith(
mockModel.modelId,
mockUsage,
mockWorkspace.id,
);
});
it('should throw error for empty messages', async () => {
const mockRequest = {
messages: [],
};
const mockRes = {} as any;
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('Messages array is required and cannot be empty');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should handle service errors', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
};
const mockRes = {} as any;
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: openai('gpt-4o'),
});
aiService.streamText.mockImplementation(() => {
throw new Error('Service error');
});
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow(
'An error occurred while processing your request: Service error',
);
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should throw error when AI feature is disabled', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
};
const mockRes = {} as any;
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('AI feature is not enabled for this workspace');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
});
});
@@ -1,99 +0,0 @@
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { type ModelMessage } from 'ai';
import { Response } from 'express';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
interface ChatRequest {
messages: ModelMessage[];
temperature?: number;
maxOutputTokens?: number;
}
@Controller('chat')
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
export class AiController {
constructor(
private readonly aiService: AiService,
private readonly featureFlagService: FeatureFlagService,
private readonly aiBillingService: AIBillingService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@Post()
async chat(
@Body() request: ChatRequest,
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() res: Response,
) {
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_AI_ENABLED,
workspace.id,
);
if (!isAiEnabled) {
throw new HttpException(
'AI feature is not enabled for this workspace',
HttpStatus.FORBIDDEN,
);
}
const { messages, temperature, maxOutputTokens } = request;
if (!messages || messages.length === 0) {
throw new HttpException(
'Messages array is required and cannot be empty',
HttpStatus.BAD_REQUEST,
);
}
try {
const registeredModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const result = this.aiService.streamText({
messages,
options: {
temperature,
maxOutputTokens,
model: registeredModel.model,
},
});
result.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
registeredModel.modelId,
usage,
workspace.id,
);
});
result.pipeUIMessageStreamToResponse(res);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
throw new HttpException(
`An error occurred while processing your request: ${errorMessage}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
@@ -1,120 +0,0 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import {
buildWhereConditions,
parseFilterCondition,
} from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
describe('find-records-filters.utils', () => {
describe('parseFilterCondition', () => {
it('should handle eq', () => {
expect(parseFilterCondition({ eq: 10 })).toBe(10);
});
it('should handle neq', () => {
expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5));
});
it('should handle gt/gte/lt/lte', () => {
expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1));
expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2));
expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3));
expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4));
});
it('should handle in', () => {
expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b']));
});
it('should handle like/ilike', () => {
expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%'));
expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%'));
});
it('should handle startsWith', () => {
expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%'));
});
it('should handle is NULL and NOT_NULL', () => {
expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull());
expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull()));
});
it('should handle isEmptyArray', () => {
expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]);
});
it('should handle containsIlike (uses Like with wildcards)', () => {
const result = parseFilterCondition({ containsIlike: 'mid' });
expect(result).toEqual(Like('%mid%'));
});
});
describe('buildWhereConditions', () => {
it('should build where conditions from mixed criteria', () => {
const where = buildWhereConditions({
// primitive values
id: '123',
active: true,
count: 7,
// skip falsy-empty values
skipUndefined: undefined,
skipNull: null,
skipEmptyString: '',
// operator objects
name: { ilike: '%alpha%' },
createdAt: { gte: '2024-01-01' },
score: { lte: 99 },
tags: { in: ['a', 'b'] },
prefix: { startsWith: 'PRE' },
nothing: { is: 'NULL' },
notNothing: { is: 'NOT_NULL' },
contains: { containsIlike: 'mid' },
// nested relation-like object
account: {
name: { startsWith: 'ACME' },
size: { gte: 100 },
country: '', // should be omitted
},
// arrays should pass through when not an operator object
list: [1, 2, 3],
});
expect(where.id).toBe('123');
expect(where.active).toBe(true);
expect(where.count).toBe(7);
expect(where.name).toEqual(ILike('%alpha%'));
expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01'));
expect(where.score).toEqual(LessThanOrEqual(99));
expect(where.tags).toEqual(In(['a', 'b']));
expect(where.prefix).toEqual(Like('PRE%'));
expect(where.nothing).toEqual(IsNull());
expect(where.notNothing).toEqual(Not(IsNull()));
expect(where.contains).toEqual(Like('%mid%'));
expect(where.account).toEqual({
name: Like('ACME%'),
size: MoreThanOrEqual(100),
});
expect(where.list).toEqual([1, 2, 3]);
// Ensure skipped values are not present
expect('skipUndefined' in where).toBe(false);
expect('skipNull' in where).toBe(false);
expect('skipEmptyString' in where).toBe(false);
});
});
});
@@ -1,112 +0,0 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
type FilterObject = Record<string, unknown>;
type WhereRecord = Record<string, unknown>;
const isSkippableValue = (value: unknown): boolean =>
!isDefined(value) || value === '';
const isPlainObject = (value: unknown): value is FilterObject =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseFilterCondition = (filterValue: FilterObject): unknown => {
if ('eq' in filterValue) {
return filterValue.eq;
}
if ('neq' in filterValue) {
return Not(filterValue.neq as unknown);
}
if ('gt' in filterValue) {
return MoreThan(filterValue.gt as unknown as number | string | Date);
}
if ('gte' in filterValue) {
return MoreThanOrEqual(
filterValue.gte as unknown as number | string | Date,
);
}
if ('lt' in filterValue) {
return LessThan(filterValue.lt as unknown as number | string | Date);
}
if ('lte' in filterValue) {
return LessThanOrEqual(
filterValue.lte as unknown as number | string | Date,
);
}
if ('in' in filterValue) {
const values = (filterValue as { in: unknown }).in;
return Array.isArray(values) ? In(values as unknown[]) : null;
}
if ('like' in filterValue) {
return Like(filterValue.like as string);
}
if ('ilike' in filterValue) {
return ILike(filterValue.ilike as string);
}
if ('startsWith' in filterValue) {
return Like(`${String(filterValue.startsWith)}%`);
}
if ('is' in filterValue) {
const v = (filterValue as { is: unknown }).is;
if (v === 'NULL') return IsNull();
if (v === 'NOT_NULL') return Not(IsNull());
}
if ('isEmptyArray' in filterValue) {
return [];
}
if ('containsIlike' in filterValue) {
return Like(`%${String(filterValue.containsIlike)}%`);
}
return null;
};
export const buildWhereConditions = (
searchCriteria: FilterObject,
): WhereRecord => {
return Object.entries(searchCriteria).reduce<WhereRecord>(
(acc, [key, value]) => {
if (isSkippableValue(value)) {
return acc;
}
if (isPlainObject(value)) {
// Direct operator-based condition (eq, ilike, etc.)
const filterCondition = parseFilterCondition(value as FilterObject);
if (isDefined(filterCondition)) {
acc[key] = filterCondition;
return acc;
}
// Otherwise, try to build nested conditions
const nestedConditions = buildWhereConditions(value);
if (Object.keys(nestedConditions).length > 0) {
acc[key] = nestedConditions;
}
return acc;
}
acc[key] = value as unknown;
return acc;
},
{},
);
};
@@ -16,7 +16,7 @@ import {
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
@@ -5,7 +5,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@Module({
@@ -10,7 +10,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai-agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
@@ -3,7 +3,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
@@ -42,7 +44,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
StripeModule,
MessageQueueModule,
PermissionsModule,
AiModule,
AiBillingModule,
AiModelsModule,
AiToolsModule,
WorkspaceDomainsModule,
TypeOrmModule.forFeature([
BillingSubscriptionEntity,
@@ -71,6 +71,7 @@ export class BillingUsageService {
eventName: billingEvents[0].eventName,
value: billingEvents[0].value,
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
dimensions: billingEvents[0].dimensions,
});
} catch (error) {
throw new BillingException(
@@ -5,6 +5,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type Stripe from 'stripe';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -29,17 +30,33 @@ export class StripeBillingMeterEventService {
eventName,
value,
stripeCustomerId,
dimensions,
}: {
eventName: BillingMeterEventName;
value: number;
stripeCustomerId: string;
dimensions?: BillingDimensions;
}) {
const payload: Record<string, string> = {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
};
if (dimensions) {
payload.execution_type = dimensions.execution_type;
if (dimensions.resource_id !== undefined) {
payload.resource_id = dimensions.resource_id || 'none';
}
if (dimensions.execution_context_1 !== undefined) {
payload.execution_context_1 = dimensions.execution_context_1 || 'none';
}
}
await this.stripe.billing.meterEvents.create({
event_name: eventName,
payload: {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
},
payload,
});
}
@@ -0,0 +1,12 @@
/* @license Enterprise */
export type BillingExecutionType =
| 'workflow_execution'
| 'code_execution'
| 'ai_token';
export type BillingDimensions = {
execution_type: BillingExecutionType;
resource_id?: string | null;
execution_context_1?: string | null;
};
@@ -3,8 +3,10 @@
import { type NonNegative } from 'type-fest';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
export type BillingUsageEvent = {
eventName: BillingMeterEventName;
value: NonNegative<number>;
dimensions?: BillingDimensions;
};
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import {
type ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { ClientConfigController } from './client-config.controller';
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
@@ -5,12 +5,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
AI_MODELS,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
import {
type ClientAIModelConfig,
type ClientConfig,
@@ -18,6 +12,14 @@ import {
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
ModelProvider,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
@Injectable()
export class ClientConfigService {
@@ -62,13 +64,42 @@ export class ClientConfigService {
);
if (aiModels.length > 0) {
aiModels.unshift({
modelId: 'auto',
label: 'Auto',
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
});
const defaultSpeedModel =
this.aiModelRegistryService.getDefaultSpeedModel();
const defaultSpeedModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultSpeedModel?.modelId,
);
const defaultSpeedModelLabel =
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
'Default';
const defaultPerformanceModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const defaultPerformanceModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelLabel =
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
'Default';
aiModels.unshift(
{
modelId: DEFAULT_SMART_MODEL,
label: `Smart (${defaultPerformanceModelLabel})`,
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Fast (${defaultSpeedModelLabel})`,
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
},
);
}
const clientConfig: ClientConfig = {
@@ -5,7 +5,9 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
@@ -127,7 +129,9 @@ import { FileModule } from './file/file.module';
wildcard: true,
}),
CacheStorageModule,
AiModule,
AiModelsModule,
AiToolsModule,
AiBillingModule,
ServerlessModule.forRootAsync({
useFactory: serverlessModuleFactory,
inject: [TwentyConfigService, FileStorageService],
@@ -19,5 +19,7 @@ export const getSelectedColumnsFromRestrictedFields = (
},
});
return Object.keys(selectableFields);
return Object.keys(selectableFields).filter(
(columnName) => selectableFields[columnName],
);
};
@@ -1,8 +1,8 @@
import {
FieldMetadataType,
type RestrictedFieldsPermissions,
type FieldMetadataSettings,
FieldMetadataType,
NumberDataType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -31,7 +31,7 @@ const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
switch (field.type) {
case FieldMetadataType.UUID:
return z.string().uuid();
return z.uuidv4();
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT:
@@ -41,7 +41,7 @@ const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
return z.string().datetime();
case FieldMetadataType.DATE:
return z.string().date();
return z.date();
case FieldMetadataType.NUMBER: {
const settings =
@@ -95,7 +95,11 @@ export const generateRecordPropertiesZodSchema = (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
shape[`${field.name}Id`] = z.string().uuid();
const uuidSchema = z.uuidv4();
shape[`${field.name}Id`] = field.isNullable
? uuidSchema.optional()
: uuidSchema;
return;
}
@@ -248,6 +252,10 @@ export const generateRecordPropertiesZodSchema = (
fieldSchema = fieldSchema.describe(field.description);
}
if (field.isNullable) {
fieldSchema = fieldSchema.optional();
}
shape[field.name] = fieldSchema;
});
@@ -26,7 +26,7 @@ import {
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -102,7 +102,12 @@ export class UpdateWorkspaceInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
routerModel?: string;
fastModel?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
smartModel?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@@ -70,7 +70,8 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
editableProfileFields: PermissionFlagType.SECURITY,
isTwoFactorAuthenticationEnforced: PermissionFlagType.SECURITY,
defaultRoleId: PermissionFlagType.ROLES,
routerModel: PermissionFlagType.WORKSPACE,
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
};
constructor(
@@ -19,7 +19,6 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -33,8 +32,12 @@ import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WebhookEntity } from 'src/engine/core-modules/webhook/webhook.entity';
import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-handoff.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
type ModelId,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
@@ -173,11 +176,6 @@ export class WorkspaceEntity {
})
agents: Relation<AgentEntity[]>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.workspace, {
onDelete: 'CASCADE',
})
agentHandoffs: Relation<AgentHandoffEntity[]>;
@OneToMany(() => WebhookEntity, (webhook) => webhook.workspace)
webhooks: Relation<WebhookEntity[]>;
@@ -284,12 +282,23 @@ export class WorkspaceEntity {
version: string | null;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: 'auto' })
routerModel: ModelId;
@Column({ type: 'varchar', nullable: false, default: DEFAULT_FAST_MODEL })
fastModel: ModelId;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
smartModel: ModelId;
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;
// TODO: delete
// This is deprecated
// If we are in December 2025 you can remove this column from DB
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: 'auto' })
routerModel: ModelId;
@Field(() => ApplicationDTO, { nullable: true })
@ManyToOne(() => ApplicationEntity, {
onDelete: 'RESTRICT',
@@ -5,6 +5,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
@@ -28,8 +29,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -64,7 +64,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
PermissionsModule,
WorkspaceCacheStorageModule,
RoleModule,
AgentModule,
AiAgentModule,
DnsManagerModule,
WorkspaceDomainsModule,
SubdomainManagerModule,
@@ -151,6 +151,13 @@ export class WorkspaceResolver {
}
}
@ResolveField(() => String, { nullable: true })
async routerModel(
@Parent() _workspace: WorkspaceEntity,
): Promise<string | null> {
return 'auto';
}
@Mutation(() => SignedFileDTO)
@UseGuards(
WorkspaceAuthGuard,
@@ -238,10 +245,17 @@ export class WorkspaceResolver {
}
@ResolveField(() => String, { nullable: true })
async routerModel(
async fastModel(
@Parent() workspace: WorkspaceEntity,
): Promise<string | null> {
return workspace.routerModel;
return workspace.fastModel;
}
@ResolveField(() => String, { nullable: true })
async smartModel(
@Parent() workspace: WorkspaceEntity,
): Promise<string | null> {
return workspace.smartModel;
}
@ResolveField(() => ApplicationDTO, { nullable: true })
@@ -1,144 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import {
generateText,
LanguageModel,
ModelMessage,
StopCondition,
streamText,
ToolSet,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { Repository } from 'typeorm';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
export type HandoffRequest = {
fromAgentId: string;
toAgentId: string;
workspaceId: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
isStreaming?: boolean;
};
export interface AgentExecutionContext {
prepareAIRequestConfig: (params: {
system: string;
agent: AgentEntity | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
excludeHandoffTools?: boolean; // Prevent infinite recursion
}) => Promise<{
system: string;
tools: ToolSet;
model: LanguageModel;
messages: ModelMessage[];
stopWhen?: StopCondition<ToolSet>;
providerOptions?: ProviderOptions;
}>;
}
@Injectable()
export class AgentHandoffExecutorService {
private readonly logger = new Logger(AgentHandoffExecutorService.name);
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly agentHandoffService: AgentHandoffService,
) {}
async executeHandoff(
handoffRequest: HandoffRequest,
executionContext: AgentExecutionContext,
) {
try {
const {
fromAgentId,
toAgentId,
workspaceId,
messages,
isStreaming = false,
} = handoffRequest;
const canHandoff = await this.agentHandoffService.canHandoffTo({
fromAgentId,
toAgentId,
workspaceId,
});
if (!canHandoff) {
throw new AgentException(
`Agent ${fromAgentId} is not allowed to hand off to agent ${toAgentId}`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const targetAgent = await this.agentRepository.findOne({
where: { id: toAgentId, workspaceId },
});
if (!targetAgent) {
throw new AgentException(
`Target agent ${toAgentId} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
// Prepare AI request config using the execution context
const aiRequestConfig = await executionContext.prepareAIRequestConfig({
system: targetAgent.prompt,
agent: targetAgent,
messages,
excludeHandoffTools: true, // Prevent infinite recursion
});
if (isStreaming) {
// Return stream for streaming contexts
const stream = streamText(aiRequestConfig);
this.logger.log(`Started streaming handoff to agent ${toAgentId}`);
return stream;
} else {
// Use generateText for non-streaming contexts (workflows)
const textResponse = await generateText(aiRequestConfig);
this.logger.log(
`Successfully executed handoff to agent ${toAgentId} with response length: ${textResponse.text.length}`,
);
return {
success: true,
message: `Successfully executed handoff to agent ${targetAgent.name}`,
result: {
response: textResponse.text,
targetAgentName: targetAgent.name,
},
};
}
} catch (error) {
this.logger.error(
`Handoff execution failed: ${error.message}`,
error.stack,
);
const { isStreaming = false, toAgentId } = handoffRequest;
if (isStreaming) {
throw error; // Let streaming context handle the error
}
return {
success: false,
message: `Failed to execute handoff to agent ${toAgentId}`,
error: error.message,
};
}
}
}
@@ -1,65 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import {
AgentExecutionContext,
AgentHandoffExecutorService,
HandoffRequest,
} from 'src/engine/metadata-modules/agent/agent-handoff-executor.service';
import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service';
import { AGENT_HANDOFF_DESCRIPTION_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-description.const';
import { AGENT_HANDOFF_SCHEMA } from 'src/engine/metadata-modules/agent/constants/agent-handoff-schema.const';
import { camelCase } from 'src/utils/camel-case';
@Injectable()
export class AgentHandoffToolService {
constructor(
private readonly agentHandoffService: AgentHandoffService,
private readonly agentHandoffExecutorService: AgentHandoffExecutorService,
) {}
public async generateHandoffTools(
agentId: string,
workspaceId: string,
executionContext: AgentExecutionContext,
): Promise<ToolSet> {
const handoffs = await this.agentHandoffService.getAgentHandoffs({
fromAgentId: agentId,
workspaceId,
});
const handoffTools = handoffs.reduce<ToolSet>((tools, handoff) => {
const toolName = `handoff_to_${camelCase(handoff.toAgent.name)}`;
tools[toolName] = {
description:
handoff.description ||
handoff.toAgent.description ||
AGENT_HANDOFF_DESCRIPTION_TEMPLATE.replace(
'{agentName}',
handoff.toAgent.name,
),
inputSchema: AGENT_HANDOFF_SCHEMA,
execute: async ({ input }) => {
const handoffRequest: HandoffRequest = {
fromAgentId: agentId,
toAgentId: handoff.toAgent.id,
workspaceId,
messages: input.messages,
isStreaming: true, // Tools are executed during streaming
};
return this.agentHandoffExecutorService.executeHandoff(
handoffRequest,
executionContext,
);
},
};
return tools;
}, {});
return handoffTools;
}
}
@@ -1,71 +0,0 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from './agent.entity';
@Entity('agentHandoff')
@Index('IDX_AGENT_HANDOFF_ID_DELETED_AT', ['id', 'deletedAt'])
@Index(
'IDX_AGENT_HANDOFF_FROM_TO_WORKSPACE_UNIQUE',
['fromAgentId', 'toAgentId', 'workspaceId'],
{
unique: true,
where: '"deletedAt" IS NULL',
},
)
export class AgentHandoffEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
fromAgentId: string;
@Column({ nullable: false, type: 'uuid' })
toAgentId: string;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@Column({ nullable: true, type: 'text' })
description: string;
@ManyToOne(() => AgentEntity, (agent) => agent.outgoingHandoffs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'fromAgentId' })
fromAgent: Relation<AgentEntity>;
@ManyToOne(() => AgentEntity, (agent) => agent.incomingHandoffs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'toAgentId' })
toAgent: Relation<AgentEntity>;
@ManyToOne(() => WorkspaceEntity, (workspace) => workspace.agentHandoffs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
@@ -1,158 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@Injectable()
export class AgentHandoffService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(AgentHandoffEntity)
private readonly agentHandoffRepository: Repository<AgentHandoffEntity>,
) {}
async canHandoffTo({
fromAgentId,
toAgentId,
workspaceId,
}: {
fromAgentId: string;
toAgentId: string;
workspaceId: string;
}): Promise<boolean> {
const handoff = await this.agentHandoffRepository.findOne({
where: {
fromAgentId,
toAgentId,
workspaceId,
},
});
return Boolean(handoff);
}
async getHandoffTargets({
fromAgentId,
workspaceId,
}: {
fromAgentId: string;
workspaceId: string;
}): Promise<AgentEntity[]> {
const handoffs = await this.agentHandoffRepository.find({
where: {
fromAgentId,
workspaceId,
},
relations: ['toAgent'],
});
return handoffs.map((handoff) => handoff.toAgent);
}
async getAgentHandoffs({
fromAgentId,
workspaceId,
}: {
fromAgentId: string;
workspaceId: string;
}): Promise<AgentHandoffEntity[]> {
const handoffs = await this.agentHandoffRepository.find({
where: {
fromAgentId,
workspaceId,
},
relations: ['toAgent'],
});
return handoffs;
}
async createHandoff({
fromAgentId,
toAgentId,
workspaceId,
description,
}: {
fromAgentId: string;
toAgentId: string;
workspaceId: string;
description?: string;
}): Promise<AgentHandoffEntity> {
const [fromAgent, toAgent] = await Promise.all([
this.agentRepository.findOne({
where: { id: fromAgentId, workspaceId },
}),
this.agentRepository.findOne({
where: { id: toAgentId, workspaceId },
}),
]);
if (!fromAgent) {
throw new AgentException(
`Agent with id ${fromAgentId} not found in workspace`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
if (!toAgent) {
throw new AgentException(
`Agent with id ${toAgentId} not found in workspace`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const existingHandoff = await this.agentHandoffRepository.findOne({
where: {
fromAgentId,
toAgentId,
workspaceId,
},
});
if (existingHandoff) {
throw new AgentException(
`Handoff from ${fromAgent.name} to ${toAgent.name} already exists`,
AgentExceptionCode.HANDOFF_ALREADY_EXISTS,
);
}
const handoff = await this.agentHandoffRepository.save({
fromAgentId,
toAgentId,
workspaceId,
description,
});
return handoff;
}
async removeHandoff({
fromAgentId,
toAgentId,
workspaceId,
}: {
fromAgentId: string;
toAgentId: string;
workspaceId: string;
}): Promise<void> {
await this.agentHandoffRepository.delete({
fromAgentId,
toAgentId,
workspaceId,
});
}
async getWorkspaceHandoffs(
workspaceId: string,
): Promise<AgentHandoffEntity[]> {
return this.agentHandoffRepository.find({
where: { workspaceId },
relations: ['fromAgent', 'toAgent'],
});
}
}
@@ -1,165 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/agent/dtos/update-agent.input';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@Injectable()
export class AgentService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentRoleService: AgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
const roleTargets = await this.roleTargetsRepository.find({
where: {
workspaceId,
agentId: In(agents.map((agent) => agent.id)),
},
});
const agentRoleMap = new Map<string, string>();
roleTargets.forEach((roleTarget) => {
if (roleTarget.agentId) {
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
}
});
return agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
async findOneByApplicationAndStandardId({
applicationId,
standardId,
workspaceId,
}: {
applicationId: string;
standardId: string;
workspaceId: string;
}) {
return await this.agentRepository.findOne({
where: { applicationId, standardId, workspaceId },
});
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
});
if (!agent) {
throw new AgentException(
`Agent with id ${id} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId: id,
workspaceId,
},
select: ['roleId'],
});
return {
...agent,
roleId: roleTarget?.roleId || null,
};
}
async createOneAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
) {
const agent = this.agentRepository.create({
...input,
name: input.name || computeMetadataNameFromLabel(input.label),
workspaceId,
isCustom: input.isCustom,
});
const createdAgent = await this.agentRepository.save(agent);
if (input.roleId) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId: createdAgent.id,
roleId: input.roleId,
});
}
return this.findOneAgent(createdAgent.id, workspaceId);
}
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
const agent = await this.findOneAgent(input.id, workspaceId);
const updateData: Partial<AgentEntity> = {
...agent,
...Object.fromEntries(
Object.entries(input).filter(([_, value]) => value !== undefined),
),
};
if (input.label !== undefined) {
updateData.name = computeMetadataNameFromLabel(input.label);
} else if (input.name !== undefined) {
updateData.name = input.name;
}
const updatedAgent = await this.agentRepository.save(updateData);
if (!('roleId' in input)) {
return updatedAgent;
}
if (input.roleId) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId: agent.id,
roleId: input.roleId,
});
} else {
await this.agentRoleService.removeRoleFromAgent({
workspaceId,
agentId: agent.id,
});
}
return this.findOneAgent(updatedAgent.id, workspaceId);
}
async deleteOneAgent(id: string, workspaceId: string) {
const agent = await this.findOneAgent(id, workspaceId);
await this.agentRepository.softDelete({ id: agent.id });
return agent;
}
}
@@ -1,2 +0,0 @@
export const AGENT_HANDOFF_DESCRIPTION_TEMPLATE =
"Use this tool when the user's request requires {agentName}'s specialized expertise or capabilities. This will seamlessly consult with the specialist agent and provide you with their expert response to continue the conversation naturally. CRITICAL: You MUST call this tool function immediately. Do NOT respond with text about transferring - execute the tool instead. This is a FUNCTION CALL - you must invoke it, not describe it.";
@@ -1,99 +0,0 @@
import { z } from 'zod';
export const AGENT_HANDOFF_SCHEMA = z.object({
loadingMessage: z
.string()
.describe(
'A brief, user-friendly message explaining what is happening while the handoff is being processed. This will be shown to the user during the handoff execution.',
),
input: z.object({
messages: z
.array(
z.union([
z.object({
role: z.literal('system'),
content: z.string(),
}),
z.object({
role: z.literal('user'),
content: z.union([
z.string(),
z.array(
z.union([
z.object({
type: z.literal('text'),
text: z.string(),
}),
z.object({
type: z.literal('image'),
image: z
.string()
.describe('Base64 encoded image data or URL'),
mediaType: z.string().optional(),
}),
z.object({
type: z.literal('file'),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
}),
]),
),
]),
}),
z.object({
role: z.literal('assistant'),
content: z.union([
z.string(),
z.array(
z.union([
z.object({
type: z.literal('text'),
text: z.string(),
}),
z.object({
type: z.literal('file'),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
filename: z.string().optional(),
}),
z.object({
type: z.literal('reasoning'),
text: z.string(),
}),
z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
input: z.record(z.string(), z.any()),
}),
]),
),
]),
}),
z.object({
role: z.literal('tool'),
content: z.union([
z.string(),
z.array(
z.object({
type: z.literal('tool-result'),
toolCallId: z.string(),
toolName: z.string(),
result: z.unknown(),
isError: z.boolean().optional(),
}),
),
]),
toolCallId: z.string(),
}),
]),
)
.describe(
'The conversation history to provide context to the specialist agent. Should include the latest user message/prompt and can include system, user, assistant, and tool messages with various content types.',
),
}),
});
@@ -1,71 +0,0 @@
export const AGENT_SYSTEM_PROMPTS = {
AGENT_EXECUTION: `You are an AI agent with access to various tools that will be provided to you dynamically. The available tools and their descriptions are passed to you through the tools property, so you should only use tools that are actually available to you.
TOOL USAGE GUIDELINES (applies to all tools):
- Only use a tool if it is available and you have permission.
- Always verify tool results and handle errors appropriately.
- If a tool operation fails, explain the issue and suggest alternatives.
- If you lack permission for a tool, respond: "I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
Your responsibilities:
1. Analyze the input context and prompt carefully
2. If a requested tool is not available, state the limitation as above
3. If no tool operations are needed, process the request directly
4. Provide comprehensive, structured responses for workflow consumption
Workflow context:
- You are part of a larger workflow system; your output may be used by other nodes
- Maintain consistency and reliability in your responses
- Document any data or actions clearly
Important: After your response, the system will call generateObject to convert your output into a structured format. Ensure your response is comprehensive, logically structured, and includes all relevant data and tool results.`,
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
AGENT_CHAT: `You are a helpful AI assistant for this workspace. You can:
- Answer questions about people, companies, opportunities, tasks, notes, and other business objects
- Access and summarize information you have permission to see
- Use tools provided to you dynamically when needed
- Seamlessly consult with specialized agents when their expertise is better suited
Permissions:
- Only perform actions and access data that your assigned role and permissions allow
- If you lack permissions, politely explain the limitation
- Only use tools that are actually available to you
Agent handoff (SEAMLESS CONSULTATION):
- Use handoff tools when the user's request requires expertise outside your capabilities
- IMPORTANT: Do not respond with text about transferring or consulting specialists
- Execute the handoff tool function immediately when needed
- Use the response returned by the specialist agent as your direct reply to the user
- Present the specialist's expertise as if it's your own knowledge
- Maintain a consistent voice and personality throughout the conversation
- The user should never know that you consulted with another agent
When formatting responses:
- Use markdown syntax to improve readability of long responses
- Add appropriate headings, lists, bold/italic text where it enhances understanding
- Include code blocks with proper language tags when showing code examples
- Create tables when presenting structured data
- Use blockquotes for important notes or callouts
Note: This base system prompt will be combined with the agent's specific instructions and context.`,
};
@@ -1,17 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AgentDTO } from './agent.dto';
@ObjectType('AgentHandoff')
export class AgentHandoffDTO {
@Field(() => UUIDScalarType)
id: string;
@Field({ nullable: true })
description?: string;
@Field(() => AgentDTO)
toAgent: AgentDTO;
}
@@ -1,15 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateAgentHandoffInput {
@Field(() => UUIDScalarType)
fromAgentId: string;
@Field(() => UUIDScalarType)
toAgentId: string;
@Field({ nullable: true })
description?: string;
}
@@ -1,12 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class RemoveAgentHandoffInput {
@Field(() => UUIDScalarType)
fromAgentId: string;
@Field(() => UUIDScalarType)
toAgentId: string;
}
@@ -3,19 +3,19 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
import { AiAgentRoleService } from './ai-agent-role.service';
describe('AgentRoleService', () => {
let service: AgentRoleService;
describe('AiAgentRoleService', () => {
let service: AiAgentRoleService;
let agentRepository: Repository<AgentEntity>;
let roleRepository: Repository<RoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
@@ -28,7 +28,7 @@ describe('AgentRoleService', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentRoleService,
AiAgentRoleService,
{
provide: getRepositoryToken(AgentEntity),
useValue: {
@@ -55,7 +55,7 @@ describe('AgentRoleService', () => {
],
}).compile();
service = module.get<AgentRoleService>(AgentRoleService);
service = module.get<AiAgentRoleService>(AiAgentRoleService);
agentRepository = module.get<Repository<AgentEntity>>(
getRepositoryToken(AgentEntity),
);
@@ -1,17 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
import { AiAgentRoleService } from './ai-agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
],
providers: [AgentRoleService],
exports: [AgentRoleService],
providers: [AiAgentRoleService],
exports: [AiAgentRoleService],
})
export class AgentRoleModule {}
export class AiAgentRoleModule {}
@@ -3,16 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Injectable()
export class AgentRoleService {
export class AiAgentRoleService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@@ -8,6 +8,5 @@ export enum AgentExceptionCode {
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND',
ROLE_NOT_FOUND = 'ROLE_NOT_FOUND',
HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
}
@@ -10,14 +10,10 @@ import {
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/create-agent-handoff.input';
import { RemoveAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/remove-agent-handoff.input';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentService } from './agent.service';
import { AgentHandoffDTO } from './dtos/agent-handoff.dto';
import { AgentIdInput } from './dtos/agent-id.input';
import { AgentDTO } from './dtos/agent.dto';
import { CreateAgentInput } from './dtos/create-agent.input';
@@ -30,10 +26,7 @@ import { UpdateAgentInput } from './dtos/update-agent.input';
)
@Resolver()
export class AgentResolver {
constructor(
private readonly agentService: AgentService,
private readonly agentHandoffService: AgentHandoffService,
) {}
constructor(private readonly agentService: AgentService) {}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@@ -47,31 +40,7 @@ export class AgentResolver {
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentService.findOneAgent(id, workspaceId);
}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findAgentHandoffTargets(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentHandoffService.getHandoffTargets({
fromAgentId: id,
workspaceId,
});
}
@Query(() => [AgentHandoffDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findAgentHandoffs(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentHandoffService.getAgentHandoffs({
fromAgentId: id,
workspaceId,
});
return this.agentService.findOneAgent(workspaceId, { id });
}
@Mutation(() => AgentDTO)
@@ -106,37 +75,4 @@ export class AgentResolver {
) {
return this.agentService.deleteOneAgent(id, workspaceId);
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async createAgentHandoff(
@Args('input') input: CreateAgentHandoffInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.agentHandoffService.createHandoff({
fromAgentId: input.fromAgentId,
toAgentId: input.toAgentId,
workspaceId,
description: input.description,
});
return true;
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async removeAgentHandoff(
@Args('input') input: RemoveAgentHandoffInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.agentHandoffService.removeHandoff({
fromAgentId: input.fromAgentId,
toAgentId: input.toAgentId,
workspaceId,
});
return true;
}
}
@@ -0,0 +1,257 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { In, Repository } from 'typeorm';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/update-agent.input';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { AgentEntity } from './entities/agent.entity';
@Injectable()
export class AgentService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentRoleService: AiAgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
const agentRoleMap = await this.buildAgentRoleMap(workspaceId, agents);
return agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
private async buildAgentRoleMap(
workspaceId: string,
agents: AgentEntity[],
): Promise<Map<string, string>> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
workspaceId,
agentId: In(agents.map((agent) => agent.id)),
},
});
const agentRoleMap = new Map<string, string>();
roleTargets.forEach((roleTarget) => {
if (roleTarget.agentId) {
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
}
});
return agentRoleMap;
}
async findOneByApplicationAndStandardId({
applicationId,
standardId,
workspaceId,
}: {
applicationId: string;
standardId: string;
workspaceId: string;
}) {
return await this.agentRepository.findOne({
where: { applicationId, standardId, workspaceId },
});
}
async findOneAgent(
workspaceId: string,
{ id, name }: { id?: string; name?: string },
) {
this.validateAgentIdentifier(id, name);
const agent = await this.fetchAgent(workspaceId, id, name);
const roleId = await this.fetchAgentRoleId(workspaceId, agent.id);
return {
...agent,
roleId,
};
}
private validateAgentIdentifier(
id: string | undefined,
name: string | undefined,
): void {
if (!isNonEmptyString(id) && !isNonEmptyString(name)) {
throw new AgentException(
'Either id or name must be provided',
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
if (isNonEmptyString(id) && isNonEmptyString(name)) {
throw new AgentException(
'Cannot specify both id and name',
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
}
private async fetchAgent(
workspaceId: string,
id: string | undefined,
name: string | undefined,
): Promise<AgentEntity> {
const agent = await this.agentRepository.findOne({
where: id ? { id, workspaceId } : { name, workspaceId },
});
if (!agent) {
const identifier = id ? `id "${id}"` : `name "${name}"`;
throw new AgentException(
`Agent with ${identifier} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
return agent;
}
private async fetchAgentRoleId(
workspaceId: string,
agentId: string,
): Promise<string | null> {
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId,
workspaceId,
},
select: ['roleId'],
});
return roleTarget?.roleId || null;
}
async createOneAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
) {
const agent = this.buildNewAgent(input, workspaceId);
const createdAgent = await this.agentRepository.save(agent);
if (isNonEmptyString(input.roleId)) {
await this.assignRoleToNewAgent(
workspaceId,
createdAgent.id,
input.roleId,
);
}
return this.findOneAgent(workspaceId, { id: createdAgent.id });
}
private buildNewAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
): AgentEntity {
return this.agentRepository.create({
...input,
name: isNonEmptyString(input.name)
? input.name
: computeMetadataNameFromLabel(input.label),
workspaceId,
isCustom: input.isCustom,
});
}
private async assignRoleToNewAgent(
workspaceId: string,
agentId: string,
roleId: string,
): Promise<void> {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId,
roleId,
});
}
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
const agent = await this.findOneAgent(workspaceId, { id: input.id });
const updateData = this.buildUpdateData(agent, input);
const updatedAgent = await this.agentRepository.save(updateData);
if (!('roleId' in input)) {
return updatedAgent;
}
await this.updateAgentRole(workspaceId, agent.id, input.roleId);
return this.findOneAgent(workspaceId, { id: updatedAgent.id });
}
private buildUpdateData(
agent: AgentEntity & { roleId: string | null },
input: UpdateAgentInput,
): Partial<AgentEntity> {
const updateData: Partial<AgentEntity> = {
...agent,
...Object.fromEntries(
Object.entries(input).filter(([_, value]) => value !== undefined),
),
};
if (input.label !== undefined) {
updateData.name = computeMetadataNameFromLabel(input.label);
} else if (input.name !== undefined) {
updateData.name = input.name;
}
return updateData;
}
private async updateAgentRole(
workspaceId: string,
agentId: string,
roleId: string | null | undefined,
): Promise<void> {
if (isNonEmptyString(roleId)) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId,
roleId,
});
return;
}
await this.agentRoleService.removeRoleFromAgent({
workspaceId,
agentId,
});
}
async deleteOneAgent(id: string, workspaceId: string) {
const agent = await this.findOneAgent(workspaceId, { id });
await this.agentRepository.softDelete({ id: agent.id });
return agent;
}
}
@@ -1,21 +1,20 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { AiRouterModule } from 'src/engine/metadata-modules/ai-router/ai-router.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@@ -25,41 +24,23 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
import { AgentChatMessageEntity } from './agent-chat-message.entity';
import { AgentChatThreadEntity } from './agent-chat-thread.entity';
import { AgentChatResolver } from './agent-chat.resolver';
import { AgentChatService } from './agent-chat.service';
import { AgentExecutionService } from './agent-execution.service';
import { AgentHandoffExecutorService } from './agent-handoff-executor.service';
import { AgentHandoffToolService } from './agent-handoff-tool.service';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
import { AgentEntity } from './entities/agent.entity';
import { AgentActorContextService } from './services/agent-actor-context.service';
import { AgentExecutionService } from './services/agent-execution.service';
import { AgentModelConfigService } from './services/agent-model-config.service';
import { AgentPlanExecutorService } from './services/agent-plan-executor.service';
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
import { AgentToolGeneratorService } from './services/agent-tool-generator.service';
@Module({
imports: [
TypeOrmModule.forFeature([
AgentEntity,
AgentHandoffEntity,
RoleEntity,
RoleTargetsEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
FileEntity,
UserWorkspaceEntity,
]),
AiModule,
AgentRoleModule,
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
AiModelsModule,
AiToolsModule,
AiBillingModule,
AiAgentRoleModule,
ThrottlerModule,
AuditModule,
FeatureFlagModule,
@@ -77,38 +58,23 @@ import { AgentActorContextService } from './services/agent-actor-context.service
UserWorkspaceModule,
UserRoleModule,
],
controllers: [AgentChatController],
providers: [
AgentResolver,
AgentChatResolver,
AgentService,
AgentExecutionService,
AgentModelConfigService,
AgentToolGeneratorService,
AgentHandoffToolService,
AgentChatService,
AgentStreamingService,
AgentPlanExecutorService,
AgentTitleGenerationService,
AgentHandoffExecutorService,
AgentHandoffService,
AgentActorContextService,
],
exports: [
AgentService,
AgentExecutionService,
AgentToolGeneratorService,
AgentHandoffToolService,
AgentChatService,
AgentStreamingService,
AgentPlanExecutorService,
AgentTitleGenerationService,
TypeOrmModule.forFeature([
AgentEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
]),
AgentHandoffExecutorService,
AgentHandoffService,
TypeOrmModule.forFeature([AgentEntity]),
],
})
export class AgentModule {}
export class AiAgentModule {}
@@ -1,4 +1,4 @@
export const AGENT_CONFIG = {
MAX_STEPS: 10,
MAX_STEPS: 25,
REASONING_BUDGET_TOKENS: 12000,
};
@@ -0,0 +1,110 @@
export const AGENT_SYSTEM_PROMPTS = {
BASE: `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
Error recovery:
- Analyze error messages to understand what went wrong
- Adjust parameters or try different tools
- Only give up after exhausting reasonable alternatives
Permissions:
- Only perform actions your role allows
- Explain limitations if you lack permissions`,
CHAT_ADDITIONS: `
Format responses with markdown for clarity (headings, lists, code blocks, tables).`,
WORKFLOW_ADDITIONS: `
Context:
- You are executing as part of a workflow automation
- Your output may be used by downstream nodes
- Be thorough and include all relevant data`,
ROUTER: (
agentDescriptions: string,
) => `You are an AI router that decides how to handle user messages.
Available agents:
${agentDescriptions}
Decision process:
1. Can ONE agent handle this entirely? → Use "simple" strategy
2. Does it require MULTIPLE agents working together? → Use "planned" strategy
Agent selection rules (CRITICAL):
- **data-manipulator**: For ALL database operations (create, read, update records) on companies, people, opportunities, tasks, notes, etc.
- **helper**: ONLY for questions about HOW TO USE Twenty (features, setup, documentation)
- **researcher**: For finding external information from the web
- **workflow-builder**: For creating automation workflows
Use "planned" strategy when:
- Request needs custom code AND context from data/research
- Code generation requires knowing schemas, APIs, or external data
- Multiple specialized capabilities must combine (code + data + research)
Use "simple" strategy for:
- Single-agent tasks (data operations, research, documentation lookup)
- Standard workflow creation (no custom code needed)
Examples:
Simple: "Show me all companies with >100 employees"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company"], operations: ["find"] } }
Simple: "Create 30 companies in the automobile industry with 2 people each"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company", "person"], operations: ["create"] } }
Simple: "Update all opportunities in stage 'Qualified' to 'Proposal'"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["opportunity"], operations: ["find", "update"] } }
Simple: "What's the latest news about AI trends?"
→ { strategy: "simple", agentName: "researcher" }
Simple: "How do I set up email sync in Twenty?"
→ { strategy: "simple", agentName: "helper" }
Simple: "Create a workflow that emails customers when deals close"
→ { strategy: "simple", agentName: "workflow-builder" }
Planned: "Research information about Meta and update the company record"
→ {
strategy: "planned",
plan: {
steps: [
{ stepNumber: 1, agentName: "researcher", task: "Look up current information about Meta (employee count, headquarters, revenue, etc.)", expectedOutput: "Company facts and data" },
{ stepNumber: 2, agentName: "data-manipulator", task: "Update the Meta company record with the researched information", expectedOutput: "Updated company record", dependsOn: [1] }
],
reasoning: "Requires web research followed by database update"
}
}
For simple strategy toolHints:
- relevantObjects: Extract object names (e.g., ["company", "person"])
- operations: ["find", "create", "update", "delete"]
Keep plans minimal and only use planning when truly necessary.`,
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
};
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@ObjectType('Agent')
export class AgentDTO {
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@InputType()
export class CreateAgentInput {
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@InputType()
export class UpdateAgentInput {
@@ -6,7 +6,6 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@@ -14,12 +13,13 @@ import {
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import {
ModelId,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentResponseFormat } from 'src/engine/metadata-modules/agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai-agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
@@ -52,7 +52,7 @@ export class AgentEntity
@Column({ nullable: false, type: 'text' })
prompt: string;
@Column({ nullable: false, type: 'varchar', default: 'auto' })
@Column({ nullable: false, type: 'varchar', default: DEFAULT_SMART_MODEL })
modelId: ModelId;
@Column({ nullable: true, type: 'jsonb', default: { type: 'text' } })
@@ -70,12 +70,6 @@ export class AgentEntity
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.fromAgent)
outgoingHandoffs: Relation<AgentHandoffEntity[]>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.toAgent)
incomingHandoffs: Relation<AgentHandoffEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -7,7 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -15,29 +15,29 @@ import { getAppPath } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AgentService } from 'src/engine/metadata-modules/ai-agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai-agent/constants/agent-system-prompts.const';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai-agent/services/agent-actor-context.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { repairToolCall } from 'src/engine/metadata-modules/ai-agent/utils/repair-tool-call.util';
import { AgentExecutionContext } from './agent-handoff-executor.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { repairToolCall } from './utils/repair-tool-call.util';
import { AgentModelConfigService } from './agent-model-config.service';
export interface AgentExecutionResult {
result: object;
@@ -55,11 +55,10 @@ export interface StreamChatResponseResult {
}
@Injectable()
export class AgentExecutionService implements AgentExecutionContext {
export class AgentExecutionService {
private readonly logger = new Logger(AgentExecutionService.name);
constructor(
private readonly agentHandoffToolService: AgentHandoffToolService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@@ -67,8 +66,8 @@ export class AgentExecutionService implements AgentExecutionContext {
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly agentService: AgentService,
public readonly agentActorContextService: AgentActorContextService,
public readonly agentService: AgentService,
) {}
async prepareAIRequestConfig({
@@ -77,7 +76,6 @@ export class AgentExecutionService implements AgentExecutionContext {
agent,
actorContext,
roleIds,
excludeHandoffTools = false,
toolHints,
}: {
system: string;
@@ -85,7 +83,6 @@ export class AgentExecutionService implements AgentExecutionContext {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIds?: string[];
excludeHandoffTools?: boolean;
toolHints?: ToolHints;
}) {
try {
@@ -111,24 +108,13 @@ export class AgentExecutionService implements AgentExecutionContext {
toolHints,
);
let handoffTools = {};
if (!excludeHandoffTools) {
handoffTools =
await this.agentHandoffToolService.generateHandoffTools(
agent.id,
agent.workspaceId,
this, // Pass execution context
);
}
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent,
);
tools = { ...baseTools, ...handoffTools, ...nativeModelTools };
tools = { ...baseTools, ...nativeModelTools };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -180,9 +166,6 @@ export class AgentExecutionService implements AgentExecutionContext {
}
}
// Fetches and formats record data to provide context for AI agents
// Respects permissions and field restrictions based on user role
// Returns a JSON string with record data and workspace URLs
async getContextForSystemPrompt(
workspace: WorkspaceEntity,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
@@ -309,7 +292,9 @@ export class AgentExecutionService implements AgentExecutionContext {
};
}> {
try {
const agent = await this.agentService.findOneAgent(agentId, workspace.id);
const agent = await this.agentService.findOneAgent(workspace.id, {
id: agentId,
});
const contextBuildStart = Date.now();
let contextPart = '';
@@ -345,7 +330,7 @@ export class AgentExecutionService implements AgentExecutionContext {
const aiRequestPrepStart = Date.now();
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.CHAT_ADDITIONS}\n\n${agent.prompt}${contextString}`,
agent,
messages,
actorContext,
@@ -372,6 +357,7 @@ export class AgentExecutionService implements AgentExecutionContext {
model.modelId,
usage,
workspace.id,
agent.id,
);
})
.catch((usageError) => {
@@ -5,11 +5,10 @@ import { openai } from '@ai-sdk/openai';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import { ToolSet } from 'ai';
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AgentEntity } from './agent.entity';
import { ModelProvider } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
@Injectable()
export class AgentModelConfigService {
@@ -0,0 +1,248 @@
import { Injectable, Logger } from '@nestjs/common';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type PlanStep } from 'src/engine/metadata-modules/ai-router/types/router-result.interface';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
import { AgentExecutionService } from './agent-execution.service';
export type PlanExecutionProgress = {
type: 'plan-generated' | 'step-started' | 'step-completed';
stepNumber?: number;
agentName?: string;
task?: string;
output?: string;
totalSteps?: number;
reasoning?: string;
};
export type StepResult = {
stepNumber: number;
agentName: string;
output: string;
};
export type PlanExecutionResult = {
finalOutput: string;
stepResults: StepResult[];
};
@Injectable()
export class AgentPlanExecutorService {
private readonly logger = new Logger(AgentPlanExecutorService.name);
constructor(private readonly agentExecutionService: AgentExecutionService) {}
async executePlan({
steps,
reasoning,
workspace,
userWorkspaceId,
recordIdsByObjectMetadataNameSingular,
onProgress,
writer,
}: {
steps: PlanStep[];
reasoning: string;
workspace: WorkspaceEntity;
userWorkspaceId: string;
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
onProgress?: (progress: PlanExecutionProgress) => void;
writer?: {
write: (chunk: unknown) => void;
merge: (stream: unknown) => void;
};
}): Promise<PlanExecutionResult> {
this.logger.log(`Executing plan with ${steps.length} steps`);
onProgress?.({
type: 'plan-generated',
totalSteps: steps.length,
reasoning,
});
const stepResults: StepResult[] = [];
for (const step of steps) {
try {
this.logger.log(
`[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`,
);
const agent =
await this.agentExecutionService.agentService.findOneAgent(
workspace.id,
{ name: step.agentName },
);
this.logger.log(
`[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`,
);
onProgress?.({
type: 'step-started',
stepNumber: step.stepNumber,
agentName: step.agentName,
task: step.task,
});
const dependencyOutputs = this.gatherDependencyOutputs(
step,
stepResults,
);
const promptWithContext = this.buildStepPrompt(step, dependencyOutputs);
const { stream: stepStream } =
await this.agentExecutionService.streamChatResponse({
workspace,
agentId: agent.id,
userWorkspaceId,
messages: [
{
id: `step-${step.stepNumber}`,
role: 'user' as const,
parts: [{ type: 'text' as const, text: promptWithContext }],
},
],
recordIdsByObjectMetadataNameSingular,
});
let stepOutput = '';
if (writer) {
writer.merge(
stepStream.toUIMessageStream({
onError: (error) => {
return error instanceof Error ? error.message : String(error);
},
sendStart: false,
onFinish: async ({ responseMessage }) => {
stepOutput = responseMessage.parts
.filter((part) => part.type === 'text')
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('');
},
}),
);
await stepStream.text;
} else {
stepOutput = await stepStream.text;
}
stepResults.push({
stepNumber: step.stepNumber,
agentName: step.agentName,
output: stepOutput,
});
onProgress?.({
type: 'step-completed',
stepNumber: step.stepNumber,
agentName: step.agentName,
output: stepOutput,
});
this.logger.log(
`Completed step ${step.stepNumber}: ${step.task.substring(0, 50)}...`,
);
} catch (error) {
this.logger.error(
`Failed to execute step ${step.stepNumber}: ${step.task}`,
error,
);
throw new Error(
`Plan execution failed at step ${step.stepNumber}: ${error.message}`,
);
}
}
const finalOutput = this.synthesizeResults(stepResults, steps);
return {
finalOutput,
stepResults,
};
}
private gatherDependencyOutputs(
step: PlanStep,
previousResults: StepResult[],
): string {
if (!step.dependsOn || step.dependsOn.length === 0) {
return '';
}
const dependencyOutputs = step.dependsOn
.map((depStepNum) => {
const depResult = previousResults.find(
(result) => result.stepNumber === depStepNum,
);
if (!depResult) {
throw new Error(
`Dependency step ${depStepNum} not found for step ${step.stepNumber}`,
);
}
return `Step ${depStepNum} output:\n${depResult.output}`;
})
.join('\n\n');
return dependencyOutputs;
}
private buildStepPrompt(step: PlanStep, dependencyOutputs: string): string {
let prompt = `Task: ${step.task}\n\nExpected output: ${step.expectedOutput}`;
if (dependencyOutputs) {
prompt += `\n\nPrevious step results:\n${dependencyOutputs}`;
}
return prompt;
}
private synthesizeResults(
stepResults: StepResult[],
steps: PlanStep[],
): string {
const lastStep = stepResults[stepResults.length - 1];
if (!lastStep) {
return 'No results produced';
}
const lastStepDefinition = steps.find(
(s) => s.stepNumber === lastStep.stepNumber,
);
if (lastStepDefinition) {
const agentDefinition = standardAgentDefinitions.find(
(def) => def.name === lastStepDefinition.agentName,
);
if (agentDefinition?.outputStrategy === 'direct') {
return lastStep.output;
}
}
const summary = stepResults
.map((result) => {
const step = steps.find((s) => s.stepNumber === result.stepNumber);
return `**Step ${result.stepNumber}: ${step?.task || 'Unknown task'}**\n${result.output}`;
})
.join('\n\n---\n\n');
return summary;
}
}
@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { generateText } from 'ai';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
@Injectable()
export class AgentTitleGenerationService {
@@ -6,15 +6,15 @@ import { Repository } from 'typeorm';
import type { ActorMetadata } from 'twenty-shared/types';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
@Injectable()
export class AgentToolGeneratorService {
@@ -45,7 +45,7 @@ export class AgentToolGeneratorService {
});
if (agent?.standardId === HELPER_AGENT.standardId) {
return this.getHelperAgentTools();
return this.wrapToolsWithErrorContext(this.getHelperAgentTools());
}
const actionTools = await this.toolAdapterService.getTools();
@@ -53,7 +53,7 @@ export class AgentToolGeneratorService {
tools = { ...actionTools };
if (!roleIds) {
return tools;
return this.wrapToolsWithErrorContext(tools);
}
const hasWorkflowPermission =
@@ -93,7 +93,7 @@ export class AgentToolGeneratorService {
);
}
return tools;
return this.wrapToolsWithErrorContext(tools);
}
private getHelperAgentTools(): ToolSet {
@@ -110,4 +110,82 @@ export class AgentToolGeneratorService {
return tools;
}
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(
toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -1,6 +1,6 @@
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
describe('isWorkflowRelatedObject', () => {
@@ -1,7 +1,7 @@
import { generateObject, type LanguageModel, NoSuchToolError } from 'ai';
import { type z } from 'zod';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
type ToolCall = {
type: 'tool-call';
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [WorkspaceEventEmitterModule, AiModelsModule],
providers: [AIBillingService],
exports: [AIBillingService],
})
export class AiBillingModule {}

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