feat: Serverless Functions as AI Tools (#16919)

## Summary

This PR enables serverless functions to be exposed as AI tools, allowing
them to be used by AI agents.

### Changes

- Added new `SERVERLESS_FUNCTION` tool category
- Added `toolDescription`, `toolInputSchema`, and `toolOutputSchema`
fields to serverless functions
- Created database migration for the new schema columns
- Added tool index query and resolver for fetching available tools
- Added Settings AI page tabs (Skills, Tools, Settings) with new tools
table
- Added utility to convert tool schema to JSON schema format
- Updated frontend to display tools in the settings page

### Implementation Details

- Serverless functions can now define tool metadata (description,
input/output schemas)
- These functions are automatically registered in the tool registry
- The tool index endpoint allows querying available tools with their
schemas
- Settings page now has a dedicated Tools tab showing all available
tools
This commit is contained in:
Félix Malfait
2026-01-05 11:13:06 +01:00
committed by GitHub
parent c0d71f7f96
commit 0173e40a20
76 changed files with 2137 additions and 1304 deletions
@@ -12,9 +12,10 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
@@ -40,7 +41,7 @@ export class AgentAsyncExecutorService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
) {}
@@ -119,21 +120,28 @@ export class AgentAsyncExecutorService {
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
// Workflow tools are excluded to prevent circular dependencies
tools = await this.toolProvider.getTools({
workspaceId: agent.workspaceId,
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
rolePermissionConfig: effectiveRoleConfig,
authContext,
actorContext,
agent: agent as unknown as Parameters<
typeof this.toolProvider.getTools
>[0]['agent'],
wrapWithErrorContext: false,
});
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
tools = await this.toolRegistry.getToolsByCategories(
{
workspaceId: agent.workspaceId,
roleId,
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
authContext,
actorContext,
agent: agent as unknown as ToolProviderContext['agent'],
userId: authContext?.user?.id,
userWorkspaceId: authContext?.userWorkspaceId,
},
{
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
wrapWithErrorContext: false,
},
);
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -387,12 +387,13 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join('
### Tool Catalog by Category`);
const categoryOrder = [
'database',
'action',
'workflow',
'dashboard',
'metadata',
'view',
'DATABASE',
'ACTION',
'WORKFLOW',
'DASHBOARD',
'METADATA',
'VIEW',
'SERVERLESS_FUNCTION',
];
for (const category of categoryOrder) {
@@ -426,18 +427,20 @@ ${tools
private getCategoryLabel(category: string): string {
switch (category) {
case 'database':
case 'DATABASE':
return 'Database Tools (CRUD operations)';
case 'action':
case 'ACTION':
return 'Action Tools (HTTP, Email, etc.)';
case 'workflow':
case 'WORKFLOW':
return 'Workflow Tools (create/manage workflows)';
case 'metadata':
case 'METADATA':
return 'Metadata Tools (schema management)';
case 'view':
case 'VIEW':
return 'View Tools (query views)';
case 'dashboard':
case 'DASHBOARD':
return 'Dashboard Tools (create/manage dashboards)';
case 'SERVERLESS_FUNCTION':
return 'Serverless Functions (custom tools)';
default:
return category;
}
@@ -10,134 +10,98 @@ import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-mod
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetFieldMetadataInputSchema = z.object({
loadingMessage: z
id: z
.string()
.uuid()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z
.string()
.uuid()
.optional()
.describe(
'Unique identifier for the field metadata. If provided, returns a single field.',
),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter fields by object metadata ID.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of fields to return.'),
}),
.describe(
'Unique identifier for the field metadata. If provided, returns a single field.',
),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter fields by object metadata ID.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of fields to return.'),
});
const CreateFieldMetadataInputSchema = z.object({
loadingMessage: z
objectMetadataId: z
.string()
.uuid()
.describe('ID of the object to add the field to'),
type: z
.nativeEnum(FieldMetadataType)
.describe(
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
),
name: z.string().describe('Internal name of the field (camelCase)'),
label: z.string().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
objectMetadataId: z
.string()
.uuid()
.describe('ID of the object to add the field to'),
type: z
.nativeEnum(FieldMetadataType)
.describe(
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
),
name: z.string().describe('Internal name of the field (camelCase)'),
label: z.string().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isNullable: z
.boolean()
.optional()
.describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z
.unknown()
.optional()
.describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
isRemoteCreation: z
.boolean()
.optional()
.describe('Whether this is a remote field creation'),
relationCreationPayload: z
.unknown()
.optional()
.describe('Payload for creating relation fields'),
}),
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
isRemoteCreation: z
.boolean()
.optional()
.describe('Whether this is a remote field creation'),
relationCreationPayload: z
.unknown()
.optional()
.describe('Payload for creating relation fields'),
});
const UpdateFieldMetadataInputSchema = z.object({
loadingMessage: z
.string()
id: z.string().uuid().describe('ID of the field to update'),
name: z.string().optional().describe('Internal name of the field'),
label: z.string().optional().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isActive: z.boolean().optional().describe('Whether the field is active'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the field to update'),
name: z.string().optional().describe('Internal name of the field'),
label: z.string().optional().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isActive: z.boolean().optional().describe('Whether the field is active'),
isNullable: z
.boolean()
.optional()
.describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z
.unknown()
.optional()
.describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
});
const DeleteFieldMetadataInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the field to delete'),
}),
id: z.string().uuid().describe('ID of the field to delete'),
});
@Injectable()
@@ -151,21 +115,21 @@ export class FieldMetadataToolsFactory {
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
inputSchema: GetFieldMetadataInputSchema,
execute: async (parameters: {
input: { id?: string; objectMetadataId?: string; limit?: number };
id?: string;
objectMetadataId?: string;
limit?: number;
}) => {
return this.fieldMetadataService.query({
filter: {
workspaceId: { eq: workspaceId },
...(parameters.input.id
? { id: { eq: parameters.input.id } }
: {}),
...(parameters.input.objectMetadataId
...(parameters.id ? { id: { eq: parameters.id } } : {}),
...(parameters.objectMetadataId
? {
objectMetadataId: { eq: parameters.input.objectMetadataId },
objectMetadataId: { eq: parameters.objectMetadataId },
}
: {}),
},
paging: { limit: parameters.input.limit ?? 100 },
paging: { limit: parameters.limit ?? 100 },
});
},
},
@@ -174,27 +138,25 @@ export class FieldMetadataToolsFactory {
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
inputSchema: CreateFieldMetadataInputSchema,
execute: async (parameters: {
input: {
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
};
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
}) => {
try {
const flatFieldMetadata =
await this.fieldMetadataService.createOneField({
createFieldInput: parameters.input as Parameters<
createFieldInput: parameters as Parameters<
typeof this.fieldMetadataService.createOneField
>[0]['createFieldInput'],
workspaceId,
@@ -214,23 +176,21 @@ export class FieldMetadataToolsFactory {
'Update an existing field metadata. Provide the field ID and the properties to update.',
inputSchema: UpdateFieldMetadataInputSchema,
execute: async (parameters: {
input: {
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
};
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters.input;
const { id, ...update } = parameters;
const flatFieldMetadata =
await this.fieldMetadataService.updateOneField({
@@ -252,11 +212,11 @@ export class FieldMetadataToolsFactory {
delete_field_metadata: {
description: 'Delete a field metadata by its ID.',
inputSchema: DeleteFieldMetadataInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const flatFieldMetadata =
await this.fieldMetadataService.deleteOneField({
deleteOneFieldInput: { id: parameters.input.id },
deleteOneFieldInput: { id: parameters.id },
workspaceId,
});
@@ -80,7 +80,7 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
),
'deletedAt',
],
propertiesToStringify: [],
propertiesToStringify: ['toolInputSchema'],
},
cronTrigger: {
propertiesToCompare: [...FLAT_CRON_TRIGGER_EDITABLE_PROPERTIES],
@@ -9,112 +9,76 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetObjectMetadataInputSchema = z.object({
loadingMessage: z
id: z
.string()
.uuid()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z
.string()
.uuid()
.optional()
.describe(
'Unique identifier for the object metadata. If provided, returns a single object.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of objects to return.'),
}),
.describe(
'Unique identifier for the object metadata. If provided, returns a single object.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of objects to return.'),
});
const CreateObjectMetadataInputSchema = z.object({
loadingMessage: z
nameSingular: z
.string()
.describe('Singular name for the object (e.g., "company")'),
namePlural: z
.string()
.describe('Plural name for the object (e.g., "companies")'),
labelSingular: z
.string()
.describe('Display label in singular form (e.g., "Company")'),
labelPlural: z
.string()
.describe('Display label in plural form (e.g., "Companies")'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isRemote: z.boolean().optional().describe('Whether this is a remote object'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
nameSingular: z
.string()
.describe('Singular name for the object (e.g., "company")'),
namePlural: z
.string()
.describe('Plural name for the object (e.g., "companies")'),
labelSingular: z
.string()
.describe('Display label in singular form (e.g., "Company")'),
labelPlural: z
.string()
.describe('Display label in plural form (e.g., "Companies")'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z
.string()
.optional()
.describe('Keyboard shortcut for the object'),
isRemote: z
.boolean()
.optional()
.describe('Whether this is a remote object'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Whether label should sync with name changes'),
});
const UpdateObjectMetadataInputSchema = z.object({
loadingMessage: z
id: z.string().uuid().describe('ID of the object to update'),
labelSingular: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the object to update'),
labelSingular: z
.string()
.optional()
.describe('Display label in singular form'),
labelPlural: z.string().optional().describe('Display label in plural form'),
nameSingular: z
.string()
.optional()
.describe('Singular name for the object'),
namePlural: z.string().optional().describe('Plural name for the object'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z
.string()
.optional()
.describe('Keyboard shortcut for the object'),
isActive: z.boolean().optional().describe('Whether the object is active'),
labelIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as label identifier'),
imageIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as image identifier'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Display label in singular form'),
labelPlural: z.string().optional().describe('Display label in plural form'),
nameSingular: z.string().optional().describe('Singular name for the object'),
namePlural: z.string().optional().describe('Plural name for the object'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isActive: z.boolean().optional().describe('Whether the object is active'),
labelIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as label identifier'),
imageIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as image identifier'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
});
const DeleteObjectMetadataInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the object to delete'),
}),
id: z.string().uuid().describe('ID of the object to delete'),
});
@Injectable()
@@ -127,17 +91,13 @@ export class ObjectMetadataToolsFactory {
description:
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
inputSchema: GetObjectMetadataInputSchema,
execute: async (parameters: {
input: { id?: string; limit?: number };
}) => {
execute: async (parameters: { id?: string; limit?: number }) => {
const flatObjectMetadatas =
await this.objectMetadataService.findManyWithinWorkspace(
workspaceId,
{
...(parameters.input.id
? { where: { id: parameters.input.id } }
: {}),
take: parameters.input.limit ?? 100,
...(parameters.id ? { where: { id: parameters.id } } : {}),
take: parameters.limit ?? 100,
},
);
@@ -151,22 +111,20 @@ export class ObjectMetadataToolsFactory {
'Create a new object metadata in the workspace data model.',
inputSchema: CreateObjectMetadataInputSchema,
execute: async (parameters: {
input: {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
};
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
}) => {
try {
const flatObjectMetadata =
await this.objectMetadataService.createOneObject({
createObjectInput: parameters.input as Parameters<
createObjectInput: parameters as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
@@ -188,23 +146,21 @@ export class ObjectMetadataToolsFactory {
'Update an existing object metadata. Provide the object ID and the fields to update.',
inputSchema: UpdateObjectMetadataInputSchema,
execute: async (parameters: {
input: {
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
};
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters.input;
const { id, ...update } = parameters;
const flatObjectMetadata =
await this.objectMetadataService.updateOneObject({
@@ -227,11 +183,11 @@ export class ObjectMetadataToolsFactory {
description:
'Delete an object metadata by its ID. This will also delete all associated fields.',
inputSchema: DeleteObjectMetadataInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const flatObjectMetadata =
await this.objectMetadataService.deleteOneObject({
deleteObjectInput: { id: parameters.input.id },
deleteObjectInput: { id: parameters.id },
workspaceId,
});
@@ -0,0 +1,9 @@
// Default tool input schema matching the base-typescript-project template
// Template params: { a: string; b: number; }
export const DEFAULT_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' },
},
};
@@ -8,4 +8,6 @@ export const FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES = [
'code',
'handlerPath',
'handlerName',
'toolInputSchema',
'isTool',
] as const satisfies (keyof FlatServerlessFunction)[];
@@ -1,6 +1,7 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -54,4 +55,14 @@ export class CreateServerlessFunctionInput {
@Field({ nullable: true })
@IsOptional()
handlerPath?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
}
@@ -7,12 +7,16 @@ import {
} from '@ptc-org/nestjs-query-graphql';
import {
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CronTriggerDTO } from 'src/engine/metadata-modules/cron-trigger/dtos/cron-trigger.dto';
@@ -69,6 +73,15 @@ export class ServerlessFunctionDTO {
@Field(() => [String], { nullable: false })
publishedVersions: string[];
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
toolInputSchema?: object;
@IsBoolean()
@Field()
isTool: boolean;
@Field(() => [CronTriggerDTO], { nullable: true })
cronTriggers?: CronTriggerDTO[];
@@ -78,6 +91,11 @@ export class ServerlessFunctionDTO {
@Field(() => [RouteTriggerDTO], { nullable: true })
routeTriggers?: RouteTriggerDTO[];
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@HideField()
workspaceId: string;
@@ -2,6 +2,7 @@ import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -50,6 +51,16 @@ class UpdateServerlessFunctionInputUpdates {
@Field({ nullable: true })
@IsOptional()
handlerPath?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
}
@InputType()
@@ -66,6 +66,12 @@ export class ServerlessFunctionEntity
@Column({ nullable: true, type: 'text' })
checksum: string | null;
@Column({ nullable: true, type: 'jsonb' })
toolInputSchema: object | null;
@Column({ nullable: false, default: false })
isTool: boolean;
@Column({ nullable: false, type: 'uuid' })
serverlessFunctionLayerId: string;
@@ -4,26 +4,29 @@ import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import deepEqual from 'deep-equal';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { Sources } from 'twenty-shared/types';
import {
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
} from 'twenty-shared/application';
import { Sources } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files';
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import { type UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
@@ -31,15 +34,13 @@ import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { cleanServerUrl } from 'src/utils/clean-server-url';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
@@ -334,6 +335,8 @@ export class ServerlessFunctionService {
name: serverlessFunctionInput.update.name,
description: serverlessFunctionInput.update.description,
timeoutSeconds: serverlessFunctionInput.update.timeoutSeconds,
toolInputSchema: serverlessFunctionInput.update.toolInputSchema,
isTool: serverlessFunctionInput.update.isTool,
},
);
@@ -405,8 +408,14 @@ export class ServerlessFunctionService {
serverlessFunctionLayerId: serverlessFunctionToCreateLayerId,
};
// If no toolInputSchema is provided, use the default schema
// (because the default template will be used for the code)
const toolInputSchema = isDefined(serverlessFunctionInput.toolInputSchema)
? serverlessFunctionInput.toolInputSchema
: DEFAULT_TOOL_INPUT_SCHEMA;
const serverlessFunctionToCreate = this.serverlessFunctionRepository.create(
{ ...createServerlessFunctionInput, workspaceId },
{ ...createServerlessFunctionInput, workspaceId, toolInputSchema },
);
const createdServerlessFunction =
@@ -1,5 +1,7 @@
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import {
DEFAULT_HANDLER_NAME,
@@ -55,5 +57,15 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
JSON.stringify(rawCreateServerlessFunctionInput.code),
)
: null,
// If no schema provided and no code provided, use default schema
// (because the default template will be used)
toolInputSchema: isDefined(
rawCreateServerlessFunctionInput?.toolInputSchema,
)
? rawCreateServerlessFunctionInput.toolInputSchema
: !isDefined(rawCreateServerlessFunctionInput?.code)
? DEFAULT_TOOL_INPUT_SCHEMA
: null,
isTool: rawCreateServerlessFunctionInput?.isTool ?? false,
};
};
@@ -120,7 +120,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_views'], {
input: { limit: 50 },
limit: 50,
});
expect(viewService.findByWorkspaceId).toHaveBeenCalledWith(
@@ -150,7 +150,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_views'], {
input: { objectNameSingular: mockObjectNameSingular, limit: 50 },
objectNameSingular: mockObjectNameSingular,
limit: 50,
});
expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith(
@@ -173,7 +174,7 @@ describe('ViewToolsFactory', () => {
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
const result = await callExecute(tools['get_views'], {
input: { limit: 2 },
limit: 2,
});
expect(result).toHaveLength(2);
@@ -201,7 +202,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_view_query_parameters'], {
input: { viewId: mockViewId },
viewId: mockViewId,
});
expect(
@@ -244,11 +245,9 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['create_view'], {
input: {
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
},
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
});
expect(viewService.createOne).toHaveBeenCalledWith({
@@ -293,10 +292,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
});
expect(viewService.updateOne).toHaveBeenCalled();
@@ -323,10 +320,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
});
expect(result.name).toBe('Updated Name');
@@ -348,10 +343,8 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
}),
).rejects.toThrow('You can only update your own unlisted views');
});
@@ -363,10 +356,8 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['update_view'], {
input: {
id: 'non-existent-id',
name: 'Updated Name',
},
id: 'non-existent-id',
name: 'Updated Name',
}),
).rejects.toThrow('View with id non-existent-id not found');
});
@@ -392,7 +383,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['delete_view'], {
input: { id: mockViewId },
id: mockViewId,
});
expect(viewService.deleteOne).toHaveBeenCalledWith({
@@ -422,7 +413,7 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['delete_view'], {
input: { id: mockViewId },
id: mockViewId,
}),
).rejects.toThrow('You can only delete your own unlisted views');
});
@@ -12,90 +12,60 @@ import { ViewService } from 'src/engine/metadata-modules/view/services/view.serv
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetViewsInputSchema = z.object({
loadingMessage: z
objectNameSingular: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
objectNameSingular: z
.string()
.optional()
.describe(
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe('Maximum views to return.'),
}),
.describe(
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe('Maximum views to return.'),
});
const GetViewQueryParamsInputSchema = z.object({
loadingMessage: z
viewId: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
viewId: z
.string()
.uuid()
.describe('ID of the view to get query parameters for.'),
}),
.uuid()
.describe('ID of the view to get query parameters for.'),
});
const CreateViewInputSchema = z.object({
loadingMessage: z
name: z.string().describe('View name'),
objectNameSingular: z
.string()
.describe(
'Object name this view is for (e.g., "task", "person", "company")',
),
icon: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
name: z.string().describe('View name'),
objectNameSingular: z
.string()
.describe(
'Object name this view is for (e.g., "task", "person", "company")',
),
icon: z
.string()
.optional()
.default('IconList')
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.default(ViewType.TABLE)
.describe('View type'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
}),
.default('IconList')
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.default(ViewType.TABLE)
.describe('View type'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
});
const UpdateViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to update'),
name: z.string().optional().describe('New view name'),
icon: z.string().optional().describe('New icon identifier'),
}),
id: z.string().uuid().describe('View ID to update'),
name: z.string().optional().describe('New view name'),
icon: z.string().optional().describe('New icon identifier'),
});
const DeleteViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to delete'),
}),
id: z.string().uuid().describe('View ID to delete'),
});
@Injectable()
@@ -142,14 +112,15 @@ export class ViewToolsFactory {
'List views in the workspace. Views define how records are displayed, filtered, and sorted.',
inputSchema: GetViewsInputSchema,
execute: async (parameters: {
input: { objectNameSingular?: string; limit?: number };
objectNameSingular?: string;
limit?: number;
}) => {
let views;
if (parameters.input.objectNameSingular) {
if (parameters.objectNameSingular) {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
parameters.objectNameSingular,
);
views = await this.viewService.findByObjectMetadataId(
@@ -164,7 +135,7 @@ export class ViewToolsFactory {
);
}
const limitedViews = views.slice(0, parameters.input.limit ?? 50);
const limitedViews = views.slice(0, parameters.limit ?? 50);
return limitedViews.map((view) => ({
id: view.id,
@@ -181,9 +152,9 @@ export class ViewToolsFactory {
description:
'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.',
inputSchema: GetViewQueryParamsInputSchema,
execute: async (parameters: { input: { viewId: string } }) => {
execute: async (parameters: { viewId: string }) => {
return this.viewQueryParamsService.resolveViewToQueryParams(
parameters.input.viewId,
parameters.viewId,
workspaceId,
currentWorkspaceMemberId,
);
@@ -199,28 +170,25 @@ export class ViewToolsFactory {
'Create a new view for an object. Views define how records are displayed.',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
input: {
name: string;
objectNameSingular: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
};
name: string;
objectNameSingular: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
parameters.objectNameSingular,
);
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.input.name,
name: parameters.name,
objectMetadataId,
icon: parameters.input.icon ?? 'IconList',
type: parameters.input.type ?? ViewType.TABLE,
visibility:
parameters.input.visibility ?? ViewVisibility.WORKSPACE,
icon: parameters.icon ?? 'IconList',
type: parameters.type ?? ViewType.TABLE,
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
@@ -229,7 +197,7 @@ export class ViewToolsFactory {
return {
id: view.id,
name: view.name,
objectNameSingular: parameters.input.objectNameSingular,
objectNameSingular: parameters.objectNameSingular,
type: view.type,
icon: view.icon,
visibility: view.visibility,
@@ -247,20 +215,18 @@ export class ViewToolsFactory {
'Update an existing view. You can change the name and icon.',
inputSchema: UpdateViewInputSchema,
execute: async (parameters: {
input: {
id: string;
name?: string;
icon?: string;
};
id: string;
name?: string;
icon?: string;
}) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
parameters.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
throw new Error(`View with id ${parameters.id} not found`);
}
if (
@@ -272,9 +238,9 @@ export class ViewToolsFactory {
const view = await this.viewService.updateOne({
updateViewInput: {
id: parameters.input.id,
name: parameters.input.name,
icon: parameters.input.icon,
id: parameters.id,
name: parameters.name,
icon: parameters.icon,
},
workspaceId,
userWorkspaceId,
@@ -299,15 +265,15 @@ export class ViewToolsFactory {
delete_view: {
description: 'Delete a view by its ID.',
inputSchema: DeleteViewInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
parameters.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
throw new Error(`View with id ${parameters.id} not found`);
}
if (
@@ -318,7 +284,7 @@ export class ViewToolsFactory {
}
const view = await this.viewService.deleteOne({
deleteViewInput: { id: parameters.input.id },
deleteViewInput: { id: parameters.id },
workspaceId,
});