feat(ai): add mcp integration (#13004)
This commit is contained in:
1
packages/twenty-front/public/images/integrations/mcp.svg
Normal file
1
packages/twenty-front/public/images/integrations/mcp.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24"><path d="M15.688 2.343a2.588 2.588 0 0 0-3.61 0l-9.626 9.44a.863.863 0 0 1-1.203 0 .823.823 0 0 1 0-1.18l9.626-9.44a4.313 4.313 0 0 1 6.016 0 4.116 4.116 0 0 1 1.204 3.54 4.3 4.3 0 0 1 3.609 1.18l.05.05a4.115 4.115 0 0 1 0 5.9l-8.706 8.537a.274.274 0 0 0 0 .393l1.788 1.754a.823.823 0 0 1 0 1.18.863.863 0 0 1-1.203 0l-1.788-1.753a1.92 1.92 0 0 1 0-2.754l8.706-8.538a2.47 2.47 0 0 0 0-3.54l-.05-.049a2.588 2.588 0 0 0-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 0 1-1.204 0 .823.823 0 0 1 0-1.18l7.273-7.133a2.47 2.47 0 0 0-.003-3.537z"/><path d="M14.485 4.703a.823.823 0 0 0 0-1.18.863.863 0 0 0-1.204 0l-7.119 6.982a4.115 4.115 0 0 0 0 5.9 4.314 4.314 0 0 0 6.016 0l7.12-6.982a.823.823 0 0 0 0-1.18.863.863 0 0 0-1.204 0l-7.119 6.982a2.588 2.588 0 0 1-3.61 0 2.47 2.47 0 0 1 0-3.54l7.12-6.982z"/></svg>
|
||||
|
After Width: | Height: | Size: 950 B |
@ -435,14 +435,6 @@ export type ConnectionParametersOutput = {
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateAgentInput = {
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
modelId: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
prompt: Scalars['String'];
|
||||
responseFormat?: InputMaybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type CreateAppTokenInput = {
|
||||
expiresAt: Scalars['DateTime'];
|
||||
};
|
||||
@ -688,6 +680,7 @@ export enum FeatureFlagKey {
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
||||
@ -1007,7 +1000,6 @@ export type Mutation = {
|
||||
createDraftFromWorkflowVersion: WorkflowVersion;
|
||||
createOIDCIdentityProvider: SetupSsoOutput;
|
||||
createObjectEvent: Analytics;
|
||||
createOneAgent: Agent;
|
||||
createOneAppToken: AppToken;
|
||||
createOneField: Field;
|
||||
createOneObject: Object;
|
||||
@ -1020,7 +1012,6 @@ export type Mutation = {
|
||||
deleteApprovedAccessDomain: Scalars['Boolean'];
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteDatabaseConfigVariable: Scalars['Boolean'];
|
||||
deleteOneAgent: Agent;
|
||||
deleteOneField: Field;
|
||||
deleteOneObject: Object;
|
||||
deleteOneRemoteServer: RemoteServer;
|
||||
@ -1154,11 +1145,6 @@ export type MutationCreateObjectEventArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneAgentArgs = {
|
||||
input: CreateAgentInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneAppTokenArgs = {
|
||||
input: CreateOneAppTokenInput;
|
||||
};
|
||||
@ -1214,11 +1200,6 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneAgentArgs = {
|
||||
input: AgentIdInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneFieldArgs = {
|
||||
input: DeleteOneFieldInput;
|
||||
};
|
||||
@ -1745,7 +1726,6 @@ export type Query = {
|
||||
field: Field;
|
||||
fields: FieldConnection;
|
||||
findDistantTablesWithStatus: Array<RemoteTable>;
|
||||
findManyAgents: Array<Agent>;
|
||||
findManyRemoteServersByType: Array<RemoteServer>;
|
||||
findManyServerlessFunctions: Array<ServerlessFunction>;
|
||||
findOneAgent: Agent;
|
||||
|
||||
@ -435,14 +435,6 @@ export type ConnectionParametersOutput = {
|
||||
username: Scalars['String'];
|
||||
};
|
||||
|
||||
export type CreateAgentInput = {
|
||||
description?: InputMaybe<Scalars['String']>;
|
||||
modelId: Scalars['String'];
|
||||
name: Scalars['String'];
|
||||
prompt: Scalars['String'];
|
||||
responseFormat?: InputMaybe<Scalars['JSON']>;
|
||||
};
|
||||
|
||||
export type CreateApprovedAccessDomainInput = {
|
||||
domain: Scalars['String'];
|
||||
email: Scalars['String'];
|
||||
@ -652,6 +644,7 @@ export enum FeatureFlagKey {
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED'
|
||||
@ -964,7 +957,6 @@ export type Mutation = {
|
||||
createDraftFromWorkflowVersion: WorkflowVersion;
|
||||
createOIDCIdentityProvider: SetupSsoOutput;
|
||||
createObjectEvent: Analytics;
|
||||
createOneAgent: Agent;
|
||||
createOneAppToken: AppToken;
|
||||
createOneField: Field;
|
||||
createOneObject: Object;
|
||||
@ -976,7 +968,6 @@ export type Mutation = {
|
||||
deleteApprovedAccessDomain: Scalars['Boolean'];
|
||||
deleteCurrentWorkspace: Workspace;
|
||||
deleteDatabaseConfigVariable: Scalars['Boolean'];
|
||||
deleteOneAgent: Agent;
|
||||
deleteOneField: Field;
|
||||
deleteOneObject: Object;
|
||||
deleteOneRole: Scalars['String'];
|
||||
@ -1105,11 +1096,6 @@ export type MutationCreateObjectEventArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneAgentArgs = {
|
||||
input: CreateAgentInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationCreateOneFieldArgs = {
|
||||
input: CreateOneFieldMetadataInput;
|
||||
};
|
||||
@ -1150,11 +1136,6 @@ export type MutationDeleteDatabaseConfigVariableArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneAgentArgs = {
|
||||
input: AgentIdInput;
|
||||
};
|
||||
|
||||
|
||||
export type MutationDeleteOneFieldArgs = {
|
||||
input: DeleteOneFieldInput;
|
||||
};
|
||||
@ -1655,7 +1636,6 @@ export type Query = {
|
||||
currentWorkspace: Workspace;
|
||||
field: Field;
|
||||
fields: FieldConnection;
|
||||
findManyAgents: Array<Agent>;
|
||||
findManyServerlessFunctions: Array<ServerlessFunction>;
|
||||
findOneAgent: Agent;
|
||||
findOneServerlessFunction: ServerlessFunction;
|
||||
|
||||
@ -8,10 +8,12 @@ import { Button } from 'twenty-ui/input';
|
||||
import {
|
||||
IconArrowUpRight,
|
||||
IconBolt,
|
||||
IconCopy,
|
||||
IconPlus,
|
||||
Status,
|
||||
} from 'twenty-ui/display';
|
||||
import { Pill } from 'twenty-ui/components';
|
||||
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
|
||||
|
||||
interface SettingsIntegrationComponentProps {
|
||||
integration: SettingsIntegration;
|
||||
@ -64,6 +66,7 @@ const StyledLogo = styled.img`
|
||||
export const SettingsIntegrationComponent = ({
|
||||
integration,
|
||||
}: SettingsIntegrationComponentProps) => {
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
return (
|
||||
<StyledContainer
|
||||
to={integration.type === 'Active' ? integration.link : undefined}
|
||||
@ -100,6 +103,17 @@ export const SettingsIntegrationComponent = ({
|
||||
title="Use"
|
||||
size="small"
|
||||
/>
|
||||
) : integration.type === 'Copy' ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (isDefined(integration.content)) {
|
||||
copyToClipboard(integration.content);
|
||||
}
|
||||
}}
|
||||
Icon={IconCopy}
|
||||
title={integration.linkText}
|
||||
size="small"
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
to={integration.link}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
|
||||
import { REACT_APP_SERVER_BASE_URL } from '~/config';
|
||||
|
||||
export const SETTINGS_INTEGRATION_AI_CATEGORY: SettingsIntegrationCategory = {
|
||||
key: 'ai',
|
||||
title: 'With AI',
|
||||
hyperlink: null,
|
||||
integrations: [
|
||||
{
|
||||
from: {
|
||||
key: 'mcp',
|
||||
image: '/images/integrations/mcp.svg',
|
||||
},
|
||||
to: null,
|
||||
type: 'Copy',
|
||||
content: JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
twenty: {
|
||||
type: 'remote',
|
||||
url: `${REACT_APP_SERVER_BASE_URL}/mcp`,
|
||||
headers: {
|
||||
Authorization: 'Bearer [API_KEY]',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
text: 'Connect MCP Client',
|
||||
link: '#',
|
||||
linkText: 'Copy',
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,21 +0,0 @@
|
||||
import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
|
||||
|
||||
export const SETTINGS_INTEGRATION_WINDMILL_CATEGORY: SettingsIntegrationCategory =
|
||||
{
|
||||
key: 'windmill',
|
||||
title: 'With Windmill',
|
||||
hyperlink: null,
|
||||
integrations: [
|
||||
{
|
||||
from: {
|
||||
key: 'windmill',
|
||||
image: '/images/integrations/windmill-logo.png',
|
||||
},
|
||||
to: null,
|
||||
type: 'Goto',
|
||||
text: 'Create a workflow with Windmill',
|
||||
link: 'https://www.windmill.dev',
|
||||
linkText: 'Go to Windmill',
|
||||
},
|
||||
],
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import { MOCK_REMOTE_DATABASES } from '@/settings/integrations/constants/MockRemoteDatabases';
|
||||
import { SETTINGS_INTEGRATION_AI_CATEGORY } from '@/settings/integrations/constants/SettingsIntegrationMcp';
|
||||
import { SETTINGS_INTEGRATION_REQUEST_CATEGORY } from '@/settings/integrations/constants/SettingsIntegrationRequest';
|
||||
import { SETTINGS_INTEGRATION_WINDMILL_CATEGORY } from '@/settings/integrations/constants/SettingsIntegrationWindmill';
|
||||
import { SETTINGS_INTEGRATION_ZAPIER_CATEGORY } from '@/settings/integrations/constants/SettingsIntegrationZapier';
|
||||
import { SettingsIntegrationCategory } from '@/settings/integrations/types/SettingsIntegrationCategory';
|
||||
import { getSettingsIntegrationAll } from '@/settings/integrations/utils/getSettingsIntegrationAll';
|
||||
@ -30,6 +30,10 @@ export const useSettingsIntegrationCategories =
|
||||
({ name }) => name === 'stripe',
|
||||
)?.isActive;
|
||||
|
||||
const isAiIntegrationEnabled = useIsFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
);
|
||||
|
||||
const allIntegrations = getSettingsIntegrationAll({
|
||||
isAirtableIntegrationEnabled,
|
||||
isAirtableIntegrationActive,
|
||||
@ -42,7 +46,7 @@ export const useSettingsIntegrationCategories =
|
||||
return [
|
||||
...(allIntegrations.integrations.length > 0 ? [allIntegrations] : []),
|
||||
SETTINGS_INTEGRATION_ZAPIER_CATEGORY,
|
||||
SETTINGS_INTEGRATION_WINDMILL_CATEGORY,
|
||||
...(isAiIntegrationEnabled ? [SETTINGS_INTEGRATION_AI_CATEGORY] : []),
|
||||
SETTINGS_INTEGRATION_REQUEST_CATEGORY,
|
||||
];
|
||||
};
|
||||
|
||||
@ -3,13 +3,15 @@ export type SettingsIntegrationType =
|
||||
| 'Add'
|
||||
| 'Goto'
|
||||
| 'Soon'
|
||||
| 'Use';
|
||||
| 'Use'
|
||||
| 'Copy';
|
||||
|
||||
export type SettingsIntegration = {
|
||||
from: { key: string; image: string };
|
||||
to?: { key: string; image: string } | null;
|
||||
type: SettingsIntegrationType;
|
||||
linkText?: string;
|
||||
content?: string;
|
||||
link: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { useMutation } from '@apollo/client';
|
||||
import { useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
import { useFindOneAgentQuery } from '~/generated-metadata/graphql';
|
||||
import { UPDATE_ONE_AGENT } from '../graphql/mutations/updateOneAgent';
|
||||
import {
|
||||
useFindOneAgentQuery,
|
||||
useUpdateOneAgentMutation,
|
||||
} from '~/generated-metadata/graphql';
|
||||
|
||||
type AgentFormValues = {
|
||||
name: string;
|
||||
@ -39,9 +40,9 @@ export const useAgentUpdateFormState = ({
|
||||
},
|
||||
});
|
||||
|
||||
const [updateAgent] = useMutation(UPDATE_ONE_AGENT);
|
||||
const [updateAgent] = useUpdateOneAgentMutation();
|
||||
|
||||
const updateAgentMutation = async (updates: Partial<AgentFormValues>) => {
|
||||
const updateAgentMutation = async (updates: AgentFormValues) => {
|
||||
if (!agentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { DynamicModule, Global, Provider } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
AiDriver,
|
||||
@ -6,11 +7,20 @@ import {
|
||||
} from 'src/engine/core-modules/ai/interfaces/ai.interface';
|
||||
|
||||
import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants';
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.service';
|
||||
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
|
||||
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
|
||||
import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver';
|
||||
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { McpController } from 'src/engine/core-modules/ai/controllers/mcp.controller';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Global()
|
||||
export class AiModule {
|
||||
@ -32,10 +42,24 @@ export class AiModule {
|
||||
|
||||
return {
|
||||
module: AiModule,
|
||||
imports: [FeatureFlagModule],
|
||||
controllers: [AiController],
|
||||
providers: [AiService, AIBillingService, provider],
|
||||
exports: [AiService, AIBillingService],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([RoleEntity], 'core'),
|
||||
FeatureFlagModule,
|
||||
ObjectMetadataModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
WorkspaceCacheStorageModule,
|
||||
UserRoleModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [AiController, McpController],
|
||||
providers: [
|
||||
AiService,
|
||||
ToolService,
|
||||
AIBillingService,
|
||||
McpService,
|
||||
provider,
|
||||
],
|
||||
exports: [AiService, AIBillingService, ToolService, McpService],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
export const MCP_SERVER_METADATA = {
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'Twenty MCP Server',
|
||||
version: '0.0.1',
|
||||
},
|
||||
};
|
||||
@ -1,6 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.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';
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
import { CoreMessage } from 'ai';
|
||||
import { Response } from 'express';
|
||||
|
||||
import { AiService } from 'src/engine/core-modules/ai/ai.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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/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 { McpController } from './mcp.controller';
|
||||
|
||||
describe('McpController', () => {
|
||||
let controller: McpController;
|
||||
let mcpService: jest.Mocked<McpService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockMcpService = {
|
||||
executeTool: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [McpController],
|
||||
providers: [
|
||||
{
|
||||
provide: McpService,
|
||||
useValue: mockMcpService,
|
||||
},
|
||||
{
|
||||
provide: AccessTokenService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
{
|
||||
provide: WorkspaceCacheStorageService,
|
||||
useValue: jest.fn(),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<McpController>(McpController);
|
||||
mcpService = module.get(McpService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
const mockWorkspace = { id: 'workspace-1' } as Workspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
it('should call mcpService.executeTool with correct parameters', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'testTool', arguments: { arg1: 'value1' } },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
content: [{ type: 'text', text: '{"result":"success"}' }],
|
||||
isError: false,
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle initialize method', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const mockResponse = {
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'A test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
mcpService.executeTool.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await controller.executeMcpMethods(
|
||||
mockRequest,
|
||||
mockWorkspace,
|
||||
mockApiKey,
|
||||
mockUserWorkspaceId,
|
||||
);
|
||||
|
||||
expect(mcpService.executeTool).toHaveBeenCalledWith(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,38 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Post,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
|
||||
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
|
||||
|
||||
@Controller('mcp')
|
||||
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
|
||||
export class McpController {
|
||||
constructor(private readonly mcpService: McpService) {}
|
||||
|
||||
@Post()
|
||||
@UsePipes(new ValidationPipe({ transform: true }))
|
||||
async executeMcpMethods(
|
||||
@Body() body: JsonRpc,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@AuthApiKey() apiKey: string | undefined,
|
||||
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
|
||||
) {
|
||||
return this.mcpService.executeTool(body, {
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
import {
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
} from 'class-validator';
|
||||
|
||||
@ValidatorConstraint({ name: 'string-or-number', async: false })
|
||||
export class IsNumberOrString implements ValidatorConstraintInterface {
|
||||
validate(value: unknown) {
|
||||
return typeof value === 'number' || typeof value === 'string';
|
||||
}
|
||||
|
||||
defaultMessage() {
|
||||
return '($value) must be number or string';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Validate,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNumberOrString } from 'src/engine/core-modules/ai/decorators/string-or-number';
|
||||
|
||||
export class JsonRpc {
|
||||
@IsString()
|
||||
@Matches(/^2\.0$/, { message: 'jsonrpc must be exactly "2.0"' })
|
||||
jsonrpc = '2.0';
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
method: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
params?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@Validate(IsNumberOrString)
|
||||
id: string | number | null;
|
||||
}
|
||||
@ -0,0 +1,392 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
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 { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
import { McpService } from './mcp.service';
|
||||
|
||||
describe('McpService', () => {
|
||||
let service: McpService;
|
||||
let featureFlagService: jest.Mocked<FeatureFlagService>;
|
||||
let toolService: jest.Mocked<ToolService>;
|
||||
let userRoleService: jest.Mocked<UserRoleService>;
|
||||
|
||||
const mockWorkspace = { id: 'workspace-1' } as Workspace;
|
||||
const mockUserWorkspaceId = 'user-workspace-1';
|
||||
const mockRoleId = 'role-1';
|
||||
const mockAdminRoleId = 'admin-role-1';
|
||||
const mockApiKey = 'api-key-1';
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockFeatureFlagService = {
|
||||
isFeatureEnabled: jest.fn(),
|
||||
};
|
||||
|
||||
const mockToolService = {
|
||||
listTools: jest.fn(),
|
||||
};
|
||||
|
||||
const mockUserRoleService = {
|
||||
getRoleIdForUserWorkspace: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAdminRole = {
|
||||
id: mockAdminRoleId,
|
||||
label: ADMIN_ROLE_LABEL,
|
||||
} as RoleEntity;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
McpService,
|
||||
{
|
||||
provide: FeatureFlagService,
|
||||
useValue: mockFeatureFlagService,
|
||||
},
|
||||
{
|
||||
provide: ToolService,
|
||||
useValue: mockToolService,
|
||||
},
|
||||
{
|
||||
provide: UserRoleService,
|
||||
useValue: mockUserRoleService,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(RoleEntity, 'core'),
|
||||
useValue: {
|
||||
find: jest.fn().mockResolvedValue([mockAdminRole]),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<McpService>(McpService);
|
||||
featureFlagService = module.get(FeatureFlagService);
|
||||
toolService = module.get(ToolService);
|
||||
userRoleService = module.get(UserRoleService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('checkAiEnabled', () => {
|
||||
it('should not throw when AI is enabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
await expect(
|
||||
service.checkAiEnabled('workspace-1'),
|
||||
).resolves.not.toThrow();
|
||||
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalledWith(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
'workspace-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
await expect(service.checkAiEnabled('workspace-1')).rejects.toThrow(
|
||||
new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleInitialize', () => {
|
||||
it('should return correct initialization response', () => {
|
||||
const requestId = '123';
|
||||
const result = service.handleInitialize(requestId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: requestId,
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRoleId', () => {
|
||||
it('should return role ID when available', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const result = await service.getRoleId('workspace-1', 'user-workspace-1');
|
||||
|
||||
expect(result).toBe(mockRoleId);
|
||||
expect(userRoleService.getRoleIdForUserWorkspace).toHaveBeenCalledWith({
|
||||
workspaceId: 'workspace-1',
|
||||
userWorkspaceId: 'user-workspace-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw when userWorkspaceId is missing and no apiKey is provided', async () => {
|
||||
await expect(service.getRoleId('workspace-1', undefined)).rejects.toThrow(
|
||||
new HttpException('User workspace ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when role ID is missing', async () => {
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(undefined);
|
||||
|
||||
await expect(
|
||||
service.getRoleId('workspace-1', 'user-workspace-1'),
|
||||
).rejects.toThrow(
|
||||
new HttpException('Role ID missing', HttpStatus.FORBIDDEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return admin role ID when apiKey is provided', async () => {
|
||||
const result = await service.getRoleId(
|
||||
'workspace-1',
|
||||
undefined,
|
||||
mockApiKey,
|
||||
);
|
||||
|
||||
expect(result).toBe(mockAdminRoleId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeTool', () => {
|
||||
it('should handle initialize method', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'initialize',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tools/call method with userWorkspaceId', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
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.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools/call method with apiKey', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
|
||||
const mockTool = {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
execute: jest.fn().mockResolvedValue({ result: 'success' }),
|
||||
};
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: mockTool,
|
||||
};
|
||||
|
||||
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.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
apiKey: mockApiKey,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: JSON.stringify({ result: 'success' }),
|
||||
},
|
||||
],
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(toolService.listTools).toHaveBeenCalledWith(
|
||||
mockAdminRoleId,
|
||||
mockWorkspace.id,
|
||||
);
|
||||
expect(mockTool.execute).toHaveBeenCalledWith(
|
||||
{ arg1: 'value1' },
|
||||
{ toolCallId: '1', messages: [] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tools listing', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
|
||||
const mockToolsMap = {
|
||||
testTool: {
|
||||
description: 'Test tool',
|
||||
parameters: { jsonSchema: { type: 'object', properties: {} } },
|
||||
},
|
||||
};
|
||||
|
||||
toolService.listTools.mockResolvedValue(mockToolsMap);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
result: {
|
||||
...MCP_SERVER_METADATA,
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: [
|
||||
{
|
||||
name: 'testTool',
|
||||
description: 'Test tool',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when AI is disabled', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/list',
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
message: 'AI feature is not enabled for this workspace',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle error when tool is not found', async () => {
|
||||
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
|
||||
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
|
||||
toolService.listTools.mockResolvedValue({});
|
||||
|
||||
const mockRequest: JsonRpc = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'tools/call',
|
||||
params: { name: 'nonExistentTool', arguments: {} },
|
||||
id: '123',
|
||||
};
|
||||
|
||||
const result = await service.executeTool(mockRequest, {
|
||||
workspace: mockWorkspace,
|
||||
userWorkspaceId: mockUserWorkspaceId,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: '123',
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
...MCP_SERVER_METADATA,
|
||||
code: HttpStatus.NOT_FOUND,
|
||||
message: "Tool 'nonExistentTool' not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,181 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { ToolSet } from 'ai';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
|
||||
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response';
|
||||
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly toolService: ToolService,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
) {}
|
||||
|
||||
async checkAiEnabled(workspaceId: string): Promise<void> {
|
||||
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_AI_ENABLED,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (!isAiEnabled) {
|
||||
throw new HttpException(
|
||||
'AI feature is not enabled for this workspace',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
handleInitialize(requestId: string | number | null) {
|
||||
return wrapJsonRpcResponse(requestId, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
prompts: { listChanged: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRoleId(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
apiKey?: string,
|
||||
) {
|
||||
if (apiKey) {
|
||||
const roles = await this.roleRepository.find({
|
||||
where: {
|
||||
workspaceId,
|
||||
label: ADMIN_ROLE_LABEL,
|
||||
},
|
||||
});
|
||||
|
||||
if (roles.length === 0) {
|
||||
throw new HttpException('Admin role not found', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roles[0].id;
|
||||
}
|
||||
|
||||
if (!userWorkspaceId) {
|
||||
throw new HttpException(
|
||||
'User workspace ID missing',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
|
||||
workspaceId,
|
||||
userWorkspaceId,
|
||||
});
|
||||
|
||||
if (!roleId) {
|
||||
throw new HttpException('Role ID missing', HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
return roleId;
|
||||
}
|
||||
|
||||
async executeTool(
|
||||
{ id, method, params }: JsonRpc,
|
||||
{
|
||||
workspace,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
}: { workspace: Workspace; userWorkspaceId?: string; apiKey?: string },
|
||||
): Promise<Record<string, unknown>> {
|
||||
try {
|
||||
await this.checkAiEnabled(workspace.id);
|
||||
|
||||
if (method === 'initialize') {
|
||||
return this.handleInitialize(id);
|
||||
}
|
||||
|
||||
const roleId = await this.getRoleId(
|
||||
workspace.id,
|
||||
userWorkspaceId,
|
||||
apiKey,
|
||||
);
|
||||
const toolSet = await this.toolService.listTools(roleId, workspace.id);
|
||||
|
||||
if (method === 'tools/call' && params) {
|
||||
return await this.handleToolCall(id, toolSet, params);
|
||||
}
|
||||
|
||||
return await this.handleToolsListing(id, toolSet);
|
||||
} catch (error) {
|
||||
return wrapJsonRpcResponse(id, {
|
||||
error: {
|
||||
code: error.status || HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
message: error.message || 'Failed to execute tool',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async handleToolCall(
|
||||
id: string | number | null,
|
||||
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 | null, toolSet: ToolSet) {
|
||||
const toolsArray = Object.entries(toolSet)
|
||||
.filter(([, def]) => !!def.parameters.jsonSchema)
|
||||
.map(([name, def]) => ({
|
||||
name,
|
||||
description: def.description,
|
||||
inputSchema: def.parameters.jsonSchema,
|
||||
}));
|
||||
|
||||
return wrapJsonRpcResponse(id, {
|
||||
result: {
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
},
|
||||
tools: toolsArray,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,785 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
ILike,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
} from 'typeorm';
|
||||
import { ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
|
||||
import {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindToolSchema,
|
||||
getRecordInputSchema,
|
||||
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
|
||||
|
||||
@Injectable()
|
||||
export class ToolService {
|
||||
constructor(
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
) {}
|
||||
|
||||
async listTools(roleId: string, workspaceId: string): Promise<ToolSet> {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache({
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const objectPermissions = rolesPermissions[roleId];
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
const filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.updateRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canRead) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
|
||||
parameters: generateFindToolSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.findRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to retrieve'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.findOneRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canSoftDelete) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to soft delete'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
roleId,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
private async findRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
take: limit as number,
|
||||
skip: offset as number,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
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(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.CREATED,
|
||||
records: [createdRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id, ...updateData } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
records: [updatedRecord],
|
||||
workspaceId,
|
||||
beforeRecords: [existingRecord],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete(id);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.remove(existingRecord);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in as string[];
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.delete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDatabaseEvent({
|
||||
objectName,
|
||||
action,
|
||||
records,
|
||||
workspaceId,
|
||||
beforeRecords,
|
||||
}: {
|
||||
objectName: string;
|
||||
action: DatabaseEventAction;
|
||||
records: Record<string, unknown>[];
|
||||
workspaceId: string;
|
||||
beforeRecords?: Record<string, unknown>[];
|
||||
}) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
nameSingular: objectName,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: objectName,
|
||||
action,
|
||||
events: records.map((record) => {
|
||||
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
|
||||
|
||||
return {
|
||||
recordId: record.id as string,
|
||||
objectMetadata,
|
||||
properties: {
|
||||
before: beforeRecord || undefined,
|
||||
after:
|
||||
action === DatabaseEventAction.DELETED ||
|
||||
action === DatabaseEventAction.DESTROYED
|
||||
? undefined
|
||||
: (record as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
}),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
|
||||
|
||||
export const wrapJsonRpcResponse = (
|
||||
id: string | number | null | undefined = null,
|
||||
payload:
|
||||
| Record<'result', Record<string, unknown>>
|
||||
| Record<'error', Record<string, unknown>>,
|
||||
) => {
|
||||
const body =
|
||||
'result' in payload
|
||||
? {
|
||||
result: { ...payload.result, ...MCP_SERVER_METADATA },
|
||||
}
|
||||
: {
|
||||
error: { ...payload.error, ...MCP_SERVER_METADATA },
|
||||
};
|
||||
|
||||
return {
|
||||
id,
|
||||
jsonrpc: '2.0',
|
||||
...body,
|
||||
};
|
||||
};
|
||||
@ -3,7 +3,6 @@ export enum FeatureFlagKey {
|
||||
IS_POSTGRESQL_INTEGRATION_ENABLED = 'IS_POSTGRESQL_INTEGRATION_ENABLED',
|
||||
IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED',
|
||||
IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED',
|
||||
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
|
||||
IS_AI_ENABLED = 'IS_AI_ENABLED',
|
||||
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
|
||||
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
|
||||
|
||||
@ -2,33 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { ToolSet } from 'ai';
|
||||
import {
|
||||
In,
|
||||
IsNull,
|
||||
LessThan,
|
||||
LessThanOrEqual,
|
||||
Like,
|
||||
MoreThan,
|
||||
MoreThanOrEqual,
|
||||
Not,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
import { z } from 'zod';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
|
||||
import {
|
||||
generateBulkDeleteToolSchema,
|
||||
generateFindToolSchema,
|
||||
getRecordInputSchema,
|
||||
} from './utils/agent-tool-schema.utils';
|
||||
import { isWorkflowRelatedObject } from './utils/is-workflow-related-object.util';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
|
||||
@Injectable()
|
||||
export class AgentToolService {
|
||||
@ -36,10 +14,7 @@ export class AgentToolService {
|
||||
private readonly agentService: AgentService,
|
||||
@InjectRepository(RoleEntity, 'core')
|
||||
private readonly roleRepository: Repository<RoleEntity>,
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
|
||||
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
|
||||
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
|
||||
private readonly toolService: ToolService,
|
||||
) {}
|
||||
|
||||
async generateToolsForAgent(
|
||||
@ -64,793 +39,9 @@ export class AgentToolService {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { data: rolesPermissions } =
|
||||
await this.workspacePermissionsCacheService.getRolesPermissionsFromCache(
|
||||
{
|
||||
workspaceId,
|
||||
},
|
||||
);
|
||||
|
||||
const objectPermissions = rolesPermissions[agent.roleId];
|
||||
|
||||
if (!objectPermissions) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const tools: ToolSet = {};
|
||||
|
||||
const allObjectMetadata =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
const filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
filteredObjectMetadata.forEach((objectMetadata) => {
|
||||
const objectPermission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!objectPermission) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (objectPermission.canUpdate) {
|
||||
tools[`create_${objectMetadata.nameSingular}`] = {
|
||||
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.createRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`update_${objectMetadata.nameSingular}`] = {
|
||||
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
|
||||
parameters: getRecordInputSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.updateRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canRead) {
|
||||
tools[`find_${objectMetadata.nameSingular}`] = {
|
||||
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination. Returns an array of matching records with their full data.`,
|
||||
parameters: generateFindToolSchema(objectMetadata),
|
||||
execute: async (parameters) => {
|
||||
return this.findRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`find_one_${objectMetadata.nameSingular}`] = {
|
||||
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to retrieve'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.findOneRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canSoftDelete) {
|
||||
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe('The unique UUID of the record to soft delete'),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.softDeleteManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (objectPermission.canDestroy) {
|
||||
tools[`destroy_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete a ${objectMetadata.labelSingular} record from the database. This action is irreversible and completely removes all data. Use with extreme caution - consider soft delete for temporary removal.`,
|
||||
parameters: z.object({
|
||||
id: z
|
||||
.string()
|
||||
.describe(
|
||||
'The unique UUID of the record to permanently delete',
|
||||
),
|
||||
}),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyRecord(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
tools[`destroy_many_${objectMetadata.nameSingular}`] = {
|
||||
description: `Permanently delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. This action is irreversible and completely removes all data from all specified records. Use with extreme caution.`,
|
||||
parameters: generateBulkDeleteToolSchema(),
|
||||
execute: async (parameters) => {
|
||||
return this.destroyManyRecords(
|
||||
objectMetadata.nameSingular,
|
||||
parameters,
|
||||
workspaceId,
|
||||
agent.roleId as string,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return tools;
|
||||
return this.toolService.listTools(role.id, workspaceId);
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async findRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { limit = 100, offset = 0, ...searchCriteria } = parameters;
|
||||
|
||||
const whereConditions = this.buildWhereConditions(searchCriteria);
|
||||
|
||||
const records = await repository.find({
|
||||
where: whereConditions,
|
||||
take: limit as number,
|
||||
skip: offset as number,
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
records,
|
||||
count: records.length,
|
||||
message: `Found ${records.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} records`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private buildWhereConditions(
|
||||
searchCriteria: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const whereConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(searchCriteria).forEach(([key, value]) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
const nestedConditions = this.buildNestedWhereConditions(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (Object.keys(nestedConditions).length > 0) {
|
||||
whereConditions[key] = nestedConditions;
|
||||
} else {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
value as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
whereConditions[key] = filterCondition;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
whereConditions[key] = value;
|
||||
});
|
||||
|
||||
return whereConditions;
|
||||
}
|
||||
|
||||
private buildNestedWhereConditions(
|
||||
nestedValue: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const nestedConditions: Record<string, unknown> = {};
|
||||
|
||||
Object.entries(nestedValue).forEach(([nestedKey, nestedFieldValue]) => {
|
||||
if (
|
||||
nestedFieldValue === undefined ||
|
||||
nestedFieldValue === null ||
|
||||
nestedFieldValue === ''
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof nestedFieldValue === 'object' &&
|
||||
!Array.isArray(nestedFieldValue)
|
||||
) {
|
||||
const filterCondition = this.parseFilterCondition(
|
||||
nestedFieldValue as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (filterCondition !== null) {
|
||||
nestedConditions[nestedKey] = filterCondition;
|
||||
}
|
||||
} else {
|
||||
nestedConditions[nestedKey] = nestedFieldValue;
|
||||
}
|
||||
});
|
||||
|
||||
return nestedConditions;
|
||||
}
|
||||
|
||||
private parseFilterCondition(filterValue: Record<string, unknown>): unknown {
|
||||
if ('eq' in filterValue) {
|
||||
return filterValue.eq;
|
||||
}
|
||||
if ('neq' in filterValue) {
|
||||
return Not(filterValue.neq);
|
||||
}
|
||||
if ('gt' in filterValue) {
|
||||
return MoreThan(filterValue.gt);
|
||||
}
|
||||
if ('gte' in filterValue) {
|
||||
return MoreThanOrEqual(filterValue.gte);
|
||||
}
|
||||
if ('lt' in filterValue) {
|
||||
return LessThan(filterValue.lt);
|
||||
}
|
||||
if ('lte' in filterValue) {
|
||||
return LessThanOrEqual(filterValue.lte);
|
||||
}
|
||||
if ('in' in filterValue) {
|
||||
return In(filterValue.in as string[]);
|
||||
}
|
||||
if ('like' in filterValue) {
|
||||
return Like(filterValue.like as string);
|
||||
}
|
||||
if ('ilike' in filterValue) {
|
||||
return Like(filterValue.ilike as string);
|
||||
}
|
||||
if ('startsWith' in filterValue) {
|
||||
return Like(`${filterValue.startsWith}%`);
|
||||
}
|
||||
if ('is' in filterValue) {
|
||||
if (filterValue.is === 'NULL') {
|
||||
return IsNull();
|
||||
}
|
||||
if (filterValue.is === 'NOT_NULL') {
|
||||
return Not(IsNull());
|
||||
}
|
||||
}
|
||||
if ('isEmptyArray' in filterValue) {
|
||||
return [];
|
||||
}
|
||||
if ('containsIlike' in filterValue) {
|
||||
return Like(`%${filterValue.containsIlike}%`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async findOneRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required',
|
||||
message: `Failed to find ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const record = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to find ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record,
|
||||
message: `Found ${objectName} record`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to find ${objectName} record`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const createdRecord = await repository.save(parameters);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.CREATED,
|
||||
records: [createdRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: createdRecord,
|
||||
message: `Successfully created ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to create ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async updateRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id, ...updateData } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for update',
|
||||
message: `Failed to update ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to update ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.update(id as string, updateData);
|
||||
|
||||
const updatedRecord = await repository.findOne({
|
||||
where: { id: id as string },
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to retrieve updated record',
|
||||
message: `Failed to update ${objectName}: Could not retrieve updated record`,
|
||||
};
|
||||
}
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.UPDATED,
|
||||
records: [updatedRecord],
|
||||
workspaceId,
|
||||
beforeRecords: [existingRecord],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
record: updatedRecord,
|
||||
message: `Successfully updated ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to update ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for soft delete',
|
||||
message: `Failed to soft delete ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to soft delete ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete(id);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully soft deleted ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyRecord(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { id } = parameters;
|
||||
|
||||
if (!id || typeof id !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record ID is required for destroy',
|
||||
message: `Failed to destroy ${objectName}: Record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecord = await repository.findOne({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!existingRecord) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Record not found',
|
||||
message: `Failed to destroy ${objectName}: Record with ID ${id} not found`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.remove(existingRecord);
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: [existingRecord],
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully destroyed ${objectName}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async softDeleteManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in;
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to soft delete',
|
||||
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.softDelete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DELETED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to soft delete many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async destroyManyRecords(
|
||||
objectName: string,
|
||||
parameters: Record<string, unknown>,
|
||||
workspaceId: string,
|
||||
roleId: string,
|
||||
) {
|
||||
try {
|
||||
const repository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
|
||||
workspaceId,
|
||||
objectName,
|
||||
{ roleId },
|
||||
);
|
||||
|
||||
const { filter } = parameters;
|
||||
|
||||
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Filter with record IDs is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: Filter with record IDs is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const idFilter = filter.id as Record<string, unknown>;
|
||||
const recordIds = idFilter.in as string[];
|
||||
|
||||
if (!Array.isArray(recordIds) || recordIds.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'At least one record ID is required for bulk destroy',
|
||||
message: `Failed to destroy many ${objectName}: At least one record ID is required`,
|
||||
};
|
||||
}
|
||||
|
||||
const existingRecords = await repository.find({
|
||||
where: { id: { in: recordIds } },
|
||||
});
|
||||
|
||||
if (existingRecords.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'No records found to destroy',
|
||||
message: `Failed to destroy many ${objectName}: No records found with the provided IDs`,
|
||||
};
|
||||
}
|
||||
|
||||
await repository.delete({ id: { in: recordIds } });
|
||||
|
||||
await this.emitDatabaseEvent({
|
||||
objectName,
|
||||
action: DatabaseEventAction.DESTROYED,
|
||||
records: existingRecords,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
count: existingRecords.length,
|
||||
message: `Successfully destroyed ${existingRecords.length} ${objectName} records`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
message: `Failed to destroy many ${objectName}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async emitDatabaseEvent({
|
||||
objectName,
|
||||
action,
|
||||
records,
|
||||
workspaceId,
|
||||
beforeRecords,
|
||||
}: {
|
||||
objectName: string;
|
||||
action: DatabaseEventAction;
|
||||
records: Record<string, unknown>[];
|
||||
workspaceId: string;
|
||||
beforeRecords?: Record<string, unknown>[];
|
||||
}) {
|
||||
const objectMetadata =
|
||||
await this.objectMetadataService.findOneWithinWorkspace(workspaceId, {
|
||||
where: {
|
||||
nameSingular: objectName,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['fields'],
|
||||
});
|
||||
|
||||
if (!objectMetadata) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.workspaceEventEmitter.emitDatabaseBatchEvent({
|
||||
objectMetadataNameSingular: objectName,
|
||||
action,
|
||||
events: records.map((record) => {
|
||||
const beforeRecord = beforeRecords?.find((r) => r.id === record.id);
|
||||
|
||||
return {
|
||||
recordId: record.id as string,
|
||||
objectMetadata,
|
||||
properties: {
|
||||
before: beforeRecord || undefined,
|
||||
after:
|
||||
action === DatabaseEventAction.DELETED ||
|
||||
action === DatabaseEventAction.DESTROYED
|
||||
? undefined
|
||||
: (record as Record<string, unknown>),
|
||||
},
|
||||
};
|
||||
}),
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,12 +9,6 @@ export class AgentException extends CustomException {
|
||||
|
||||
export enum AgentExceptionCode {
|
||||
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
|
||||
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
|
||||
AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS',
|
||||
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
|
||||
AGENT_EXECUTION_LIMIT_REACHED = 'AGENT_EXECUTION_LIMIT_REACHED',
|
||||
AGENT_INVALID_PROMPT = 'AGENT_INVALID_PROMPT',
|
||||
AGENT_INVALID_MODEL = 'AGENT_INVALID_MODEL',
|
||||
UNSUPPORTED_MODEL = 'UNSUPPORTED_MODEL',
|
||||
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ import { AgentService } from './agent.service';
|
||||
|
||||
import { AgentIdInput } from './dtos/agent-id.input';
|
||||
import { AgentDTO } from './dtos/agent.dto';
|
||||
import { CreateAgentInput } from './dtos/create-agent.input';
|
||||
import { UpdateAgentInput } from './dtos/update-agent.input';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@ -31,21 +30,6 @@ export class AgentResolver {
|
||||
return this.agentService.findOneAgent(id, workspaceId);
|
||||
}
|
||||
|
||||
@Query(() => [AgentDTO])
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
|
||||
return this.agentService.findManyAgents(workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async createOneAgent(
|
||||
@Args('input') input: CreateAgentInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.createOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async updateOneAgent(
|
||||
@ -54,13 +38,4 @@ export class AgentResolver {
|
||||
) {
|
||||
return this.agentService.updateOneAgent(input, workspaceId);
|
||||
}
|
||||
|
||||
@Mutation(() => AgentDTO)
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
|
||||
async deleteOneAgent(
|
||||
@Args('input') { id }: AgentIdInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
return this.agentService.deleteOneAgent(id, workspaceId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,251 +1,170 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { AGENT_GQL_FIELDS } from 'test/integration/constants/agent-gql-fields.constants';
|
||||
import { createAgentOperation } from 'test/integration/graphql/utils/create-agent-operation-factory.util';
|
||||
import { deleteAgentOperation } from 'test/integration/graphql/utils/delete-agent-operation-factory.util';
|
||||
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
||||
import { updateAgentOperation } from 'test/integration/graphql/utils/update-agent-operation-factory.util';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
|
||||
import { AgentResolver } from 'src/engine/metadata-modules/agent/agent.resolver';
|
||||
import {
|
||||
AgentException,
|
||||
AgentExceptionCode,
|
||||
} from 'src/engine/metadata-modules/agent/agent.exception';
|
||||
|
||||
// Mock the agent service
|
||||
jest.mock('../../../../../src/engine/metadata-modules/agent/agent.service');
|
||||
|
||||
// Mock the guards and decorators
|
||||
jest.mock('../../../../../src/engine/guards/feature-flag.guard', () => ({
|
||||
FeatureFlagGuard: jest.fn().mockImplementation(() => ({
|
||||
canActivate: jest.fn().mockReturnValue(true),
|
||||
})),
|
||||
RequireFeatureFlag: () => jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../src/engine/guards/workspace-auth.guard', () => ({
|
||||
WorkspaceAuthGuard: jest.fn().mockImplementation(() => ({
|
||||
canActivate: jest.fn().mockReturnValue(true),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('agentResolver', () => {
|
||||
describe('createOneAgent', () => {
|
||||
it('should create an agent successfully', async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Test AI Agent Admin',
|
||||
description: 'A test AI agent created by admin',
|
||||
prompt: 'You are a helpful AI assistant for testing.',
|
||||
modelId: 'gpt-4o',
|
||||
responseFormat: { type: 'json_object' },
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.createOneAgent).toBeDefined();
|
||||
expect(response.body.data.createOneAgent.id).toBeDefined();
|
||||
expect(response.body.data.createOneAgent.name).toBe(
|
||||
'Test AI Agent Admin',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.description).toBe(
|
||||
'A test AI agent created by admin',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.prompt).toBe(
|
||||
'You are a helpful AI assistant for testing.',
|
||||
);
|
||||
expect(response.body.data.createOneAgent.modelId).toBe('gpt-4o');
|
||||
expect(response.body.data.createOneAgent.responseFormat).toEqual({
|
||||
type: 'json_object',
|
||||
});
|
||||
await makeGraphqlAPIRequest(
|
||||
deleteAgentOperation(response.body.data.createOneAgent.id),
|
||||
);
|
||||
});
|
||||
|
||||
it('should validate required fields and return error', async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: undefined as any,
|
||||
description: 'Agent without required fields',
|
||||
prompt: undefined as any,
|
||||
modelId: undefined as any,
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain(
|
||||
'Field "name" of required type "String!" was not provided',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
let agentService: AgentService;
|
||||
let agentResolver: AgentResolver;
|
||||
let module: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
// Create a testing module with mocked dependencies
|
||||
module = await Test.createTestingModule({
|
||||
providers: [
|
||||
AgentResolver,
|
||||
{
|
||||
provide: AgentService,
|
||||
useValue: {
|
||||
findOneAgent: jest.fn(),
|
||||
updateOneAgent: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
// Get the mocked services from the module
|
||||
agentService = module.get<AgentService>(AgentService);
|
||||
agentResolver = module.get<AgentResolver>(AgentResolver);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (module) {
|
||||
await module.close();
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('findOneAgent', () => {
|
||||
const testAgentId = 'test-agent-id';
|
||||
const workspaceId = 'test-workspace-id';
|
||||
const mockAgent = {
|
||||
id: testAgentId,
|
||||
name: 'Test Agent for Find',
|
||||
description: 'A test agent for find operations',
|
||||
prompt: 'You are a test agent for finding.',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
roleId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
|
||||
});
|
||||
it('should find agent by ID successfully', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: testAgentId } },
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
// Mock the findOneAgent method to return a mock agent
|
||||
(agentService.findOneAgent as jest.Mock).mockResolvedValueOnce(mockAgent);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.findOneAgent).toBeDefined();
|
||||
expect(response.body.data.findOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.findOneAgent.name).toBe('Test Agent for Find');
|
||||
});
|
||||
it('should return 404 error for non-existent agent', async () => {
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: nonExistentId } },
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
// Call the resolver directly
|
||||
const result = await agentResolver.findOneAgent({ id: testAgentId }, {
|
||||
id: workspaceId,
|
||||
} as any);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findManyAgents', () => {
|
||||
const testAgentIds: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const operation = createAgentOperation({
|
||||
name: `Test Agent ${i + 1}`,
|
||||
description: `A test agent ${i + 1} for find many operations`,
|
||||
prompt: `You are test agent ${i + 1}.`,
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentIds.push(response.body.data.createOneAgent.id);
|
||||
}
|
||||
});
|
||||
afterAll(async () => {
|
||||
for (const agentId of testAgentIds) {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(agentId));
|
||||
}
|
||||
});
|
||||
it('should find all agents successfully', async () => {
|
||||
const queryData = {
|
||||
query: gql`
|
||||
query FindManyAgents {
|
||||
findManyAgents {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
const response = await makeGraphqlAPIRequest(queryData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.errors).toBeUndefined();
|
||||
expect(response.body.data.findManyAgents).toBeDefined();
|
||||
expect(Array.isArray(response.body.data.findManyAgents)).toBe(true);
|
||||
expect(response.body.data.findManyAgents.length).toBeGreaterThanOrEqual(
|
||||
3,
|
||||
// Verify the service was called with the correct parameters
|
||||
expect(agentService.findOneAgent).toHaveBeenCalledWith(
|
||||
testAgentId,
|
||||
workspaceId,
|
||||
);
|
||||
const testAgentNames = response.body.data.findManyAgents
|
||||
.filter((agent: any) => testAgentIds.includes(agent.id))
|
||||
.map((agent: any) => agent.name);
|
||||
|
||||
expect(testAgentNames).toContain('Test Agent 1');
|
||||
expect(testAgentNames).toContain('Test Agent 2');
|
||||
expect(testAgentNames).toContain('Test Agent 3');
|
||||
// Verify the result matches our expectations
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(testAgentId);
|
||||
expect(result.name).toBe('Test Agent for Find');
|
||||
});
|
||||
|
||||
it('should throw an error for non-existent agent', async () => {
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
// Mock the findOneAgent method to throw an exception
|
||||
(agentService.findOneAgent as jest.Mock).mockRejectedValueOnce(
|
||||
new AgentException(
|
||||
`Agent with id ${nonExistentId} not found`,
|
||||
AgentExceptionCode.AGENT_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
// Call the resolver and expect it to throw
|
||||
await expect(
|
||||
agentResolver.findOneAgent({ id: nonExistentId }, {
|
||||
id: workspaceId,
|
||||
} as any),
|
||||
).rejects.toThrow(AgentException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
const testAgentId = 'test-agent-id';
|
||||
const workspaceId = 'test-workspace-id';
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Original Test Agent',
|
||||
description: 'Original description',
|
||||
prompt: 'Original prompt',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
await makeGraphqlAPIRequest(deleteAgentOperation(testAgentId));
|
||||
});
|
||||
it('should update an agent successfully', async () => {
|
||||
const operation = updateAgentOperation({
|
||||
const updatedAgent = {
|
||||
id: testAgentId,
|
||||
name: 'Updated Test Agent Admin',
|
||||
description: 'Updated description',
|
||||
prompt: 'Updated prompt for admin',
|
||||
modelId: 'gpt-4o-mini',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.updateOneAgent).toBeDefined();
|
||||
expect(response.body.data.updateOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.updateOneAgent.name).toBe(
|
||||
'Updated Test Agent Admin',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.description).toBe(
|
||||
'Updated description',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.prompt).toBe(
|
||||
'Updated prompt for admin',
|
||||
);
|
||||
expect(response.body.data.updateOneAgent.modelId).toBe('gpt-4o-mini');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOneAgent', () => {
|
||||
let testAgentId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const operation = createAgentOperation({
|
||||
name: 'Agent to Delete',
|
||||
description: 'This agent will be deleted',
|
||||
prompt: 'You are an agent that will be deleted.',
|
||||
modelId: 'gpt-4o',
|
||||
});
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
testAgentId = response.body.data.createOneAgent.id;
|
||||
});
|
||||
it('should delete an agent successfully', async () => {
|
||||
const operation = deleteAgentOperation(testAgentId);
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
|
||||
expect(response.body.data).toBeDefined();
|
||||
expect(response.body.data.deleteOneAgent).toBeDefined();
|
||||
expect(response.body.data.deleteOneAgent.id).toBe(testAgentId);
|
||||
expect(response.body.data.deleteOneAgent.name).toBe('Agent to Delete');
|
||||
const findQueryData = {
|
||||
query: gql`
|
||||
query FindOneAgent($input: AgentIdInput!) {
|
||||
findOneAgent(input: $input) {
|
||||
${AGENT_GQL_FIELDS}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { input: { id: testAgentId } },
|
||||
roleId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
const findResponse = await makeGraphqlAPIRequest(findQueryData);
|
||||
|
||||
expect(findResponse.body.errors).toBeDefined();
|
||||
expect(findResponse.body.errors[0].message).toContain('not found');
|
||||
});
|
||||
it('should return 404 error for non-existent agent', async () => {
|
||||
const nonExistentId = '00000000-0000-0000-0000-000000000000';
|
||||
const operation = deleteAgentOperation(nonExistentId);
|
||||
const response = await makeGraphqlAPIRequest(operation);
|
||||
it('should update an agent successfully', async () => {
|
||||
// Mock the updateOneAgent method to return the updated agent
|
||||
(agentService.updateOneAgent as jest.Mock).mockResolvedValueOnce(
|
||||
updatedAgent,
|
||||
);
|
||||
|
||||
expect(response.body.errors).toBeDefined();
|
||||
expect(response.body.errors[0].message).toContain('not found');
|
||||
// Call the resolver directly
|
||||
const result = await agentResolver.updateOneAgent(
|
||||
{
|
||||
id: testAgentId,
|
||||
name: 'Updated Test Agent Admin',
|
||||
description: 'Updated description',
|
||||
prompt: 'Updated prompt for admin',
|
||||
modelId: 'gpt-4o-mini',
|
||||
},
|
||||
{ id: workspaceId } as any,
|
||||
);
|
||||
|
||||
// Verify the service was called with the correct parameters
|
||||
expect(agentService.updateOneAgent).toHaveBeenCalledWith(
|
||||
{
|
||||
id: testAgentId,
|
||||
name: 'Updated Test Agent Admin',
|
||||
description: 'Updated description',
|
||||
prompt: 'Updated prompt for admin',
|
||||
modelId: 'gpt-4o-mini',
|
||||
},
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
// Verify the result matches our expectations
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(testAgentId);
|
||||
expect(result.name).toBe('Updated Test Agent Admin');
|
||||
expect(result.description).toBe('Updated description');
|
||||
expect(result.prompt).toBe('Updated prompt for admin');
|
||||
expect(result.modelId).toBe('gpt-4o-mini');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const updateFeatureFlagFactory = (
|
||||
workspaceId: string,
|
||||
featureFlag: string,
|
||||
value: boolean,
|
||||
) => ({
|
||||
query: gql`
|
||||
mutation UpdateWorkspaceFeatureFlag(
|
||||
$workspaceId: String!
|
||||
$featureFlag: String!
|
||||
$value: Boolean!
|
||||
) {
|
||||
updateWorkspaceFeatureFlag(
|
||||
workspaceId: $workspaceId
|
||||
featureFlag: $featureFlag
|
||||
value: $value
|
||||
)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
workspaceId,
|
||||
featureFlag,
|
||||
value,
|
||||
},
|
||||
});
|
||||
@ -62,15 +62,13 @@ describe('AgentToolService Integration', () => {
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(8);
|
||||
expect(Object.keys(tools)).toHaveLength(6);
|
||||
expect(Object.keys(tools)).toContain('create_testObject');
|
||||
expect(Object.keys(tools)).toContain('update_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_many_testObject');
|
||||
expect(Object.keys(tools)).toContain('destroy_testObject');
|
||||
expect(Object.keys(tools)).toContain('destroy_many_testObject');
|
||||
});
|
||||
|
||||
it('should generate read-only tools for agent with read permissions only', async () => {
|
||||
@ -646,156 +644,6 @@ describe('AgentToolService Integration', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Destroy Operations', () => {
|
||||
it('should destroy a single record', async () => {
|
||||
const roleWithDestroyPermission = {
|
||||
...context.testRole,
|
||||
canDestroyAllObjectRecords: true,
|
||||
};
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecord = createTestRecord('test-record-id', {
|
||||
name: 'Test Record',
|
||||
});
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(roleWithDestroyPermission);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.findOne.mockResolvedValue(existingRecord);
|
||||
mockRepository.remove.mockResolvedValue(existingRecord);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const destroyTool = tools['destroy_testObject'];
|
||||
|
||||
expect(destroyTool).toBeDefined();
|
||||
|
||||
if (!destroyTool.execute) {
|
||||
throw new Error(
|
||||
'Destroy tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await destroyTool.execute(
|
||||
{ id: 'test-record-id' },
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Destroy record',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(result, 'Successfully destroyed testObject');
|
||||
expect(mockRepository.remove).toHaveBeenCalledWith(existingRecord);
|
||||
});
|
||||
|
||||
it('should destroy multiple records', async () => {
|
||||
const roleWithDestroyPermission = {
|
||||
...context.testRole,
|
||||
canDestroyAllObjectRecords: true,
|
||||
};
|
||||
const mockRepository = createMockRepository();
|
||||
const existingRecords = createTestRecords(3);
|
||||
|
||||
jest
|
||||
.spyOn(context.agentService, 'findOneAgent')
|
||||
.mockResolvedValue(context.testAgent as any);
|
||||
jest
|
||||
.spyOn(context.roleRepository, 'findOne')
|
||||
.mockResolvedValue(roleWithDestroyPermission);
|
||||
jest
|
||||
.spyOn(
|
||||
context.workspacePermissionsCacheService,
|
||||
'getRolesPermissionsFromCache',
|
||||
)
|
||||
.mockResolvedValue({
|
||||
data: {
|
||||
[context.testRoleId]: {
|
||||
[context.testObjectMetadata.id]: {
|
||||
canRead: true,
|
||||
canUpdate: true,
|
||||
canSoftDelete: true,
|
||||
canDestroy: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
version: '1.0',
|
||||
});
|
||||
jest
|
||||
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
|
||||
.mockResolvedValue([context.testObjectMetadata]);
|
||||
|
||||
setupRepositoryMock(context, mockRepository);
|
||||
mockRepository.find.mockResolvedValue(existingRecords);
|
||||
mockRepository.remove.mockResolvedValue(existingRecords);
|
||||
|
||||
const tools = await context.agentToolService.generateToolsForAgent(
|
||||
context.testAgentId,
|
||||
context.testWorkspaceId,
|
||||
);
|
||||
const destroyManyTool = tools['destroy_many_testObject'];
|
||||
|
||||
expect(destroyManyTool).toBeDefined();
|
||||
|
||||
if (!destroyManyTool.execute) {
|
||||
throw new Error(
|
||||
'Destroy many tool is missing or does not have an execute method',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await destroyManyTool.execute(
|
||||
{
|
||||
filter: { id: { in: ['record-1', 'record-2', 'record-3'] } },
|
||||
},
|
||||
{
|
||||
toolCallId: 'test-tool-call-id',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Destroy many records',
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
expectSuccessResult(
|
||||
result,
|
||||
'Successfully destroyed 3 testObject records',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle empty search criteria in find records', async () => {
|
||||
const mockRepository = createMockRepository();
|
||||
|
||||
@ -12,6 +12,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
|
||||
export interface AgentToolTestContext {
|
||||
module: TestingModule;
|
||||
@ -77,6 +78,10 @@ export const createAgentToolTestModule =
|
||||
getRolesPermissionsFromCache: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ToolService,
|
||||
useClass: ToolService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { ObjectRecordsPermissions } from "@/types/ObjectRecordsPermissions";
|
||||
import { ObjectRecordsPermissions } from '@/types/ObjectRecordsPermissions';
|
||||
|
||||
type RoleId = string;
|
||||
export type ObjectRecordsPermissionsByRoleId = Record<
|
||||
|
||||
@ -4102,8 +4102,6 @@ import {
|
||||
IconWifiOff,
|
||||
IconWind,
|
||||
IconWindOff,
|
||||
IconWindmill,
|
||||
IconWindmillOff,
|
||||
IconWindow,
|
||||
IconWindowMaximize,
|
||||
IconWindowMinimize,
|
||||
@ -8288,8 +8286,6 @@ export const ALL_ICONS = {
|
||||
IconWifiOff,
|
||||
IconWind,
|
||||
IconWindOff,
|
||||
IconWindmill,
|
||||
IconWindmillOff,
|
||||
IconWindow,
|
||||
IconWindowMaximize,
|
||||
IconWindowMinimize,
|
||||
|
||||
@ -8,7 +8,7 @@ sectionInfo: Learn how to connect Twenty to your other tools.
|
||||
|
||||
## About
|
||||
|
||||
Integration with Zapier and Windmill is available for automating your workflows.
|
||||
Integration with Zapier is available for automating your workflows.
|
||||
|
||||
## Zapier
|
||||
|
||||
|
||||
Reference in New Issue
Block a user