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
@@ -703,6 +703,8 @@ export class ApplicationSyncService {
timeoutSeconds: serverlessFunctionToSync.timeoutSeconds,
handlerPath: serverlessFunctionToSync.handlerPath,
handlerName: serverlessFunctionToSync.handlerName,
toolInputSchema: serverlessFunctionToSync.toolInputSchema,
isTool: serverlessFunctionToSync.isTool,
},
};
@@ -747,6 +749,8 @@ export class ApplicationSyncService {
handlerName: serverlessFunctionToCreate.handlerName,
applicationId,
serverlessFunctionLayerId,
toolInputSchema: serverlessFunctionToCreate.toolInputSchema,
isTool: serverlessFunctionToCreate.isTool,
};
const createdServerlessFunction =
@@ -52,7 +52,13 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
restrictedFields,
),
execute: async (parameters) => {
const { limit, offset, orderBy, ...filter } = parameters.input;
const {
loadingMessage: _,
limit,
offset,
orderBy,
...filter
} = parameters;
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
@@ -72,7 +78,7 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
execute: async (parameters) => {
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.input.id } },
filter: { id: { eq: parameters.id } },
limit: 1,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
@@ -89,9 +95,11 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
restrictedFields,
),
execute: async (parameters) => {
const { loadingMessage: _, ...objectRecord } = parameters;
return deps.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
@@ -108,7 +116,7 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
restrictedFields,
),
execute: async (parameters) => {
const { id, ...allFields } = parameters.input;
const { loadingMessage: _, id, ...allFields } = parameters;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
@@ -134,7 +142,7 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
execute: async (parameters) => {
return deps.deleteRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.input.id,
objectRecordId: parameters.id,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
@@ -1,5 +1,4 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
@@ -8,19 +7,9 @@ export const generateCreateRecordInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordPropertiesSchema = generateRecordPropertiesZodSchema(
return generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
return z.object({
loadingMessage: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: recordPropertiesSchema,
});
};
@@ -14,20 +14,10 @@ export const generateUpdateRecordInputSchema = (
restrictedFields,
);
const updateSchema = recordPropertiesSchema.partial().extend({
return recordPropertiesSchema.partial().extend({
id: z.string().uuid({
message:
'The unique identifier (UUID) of the record to update. This is required to identify which record should be modified.',
}),
});
return z.object({
loadingMessage: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: updateSchema,
});
};
@@ -1,25 +1,17 @@
import { z } from 'zod';
export const BulkDeleteToolInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: z.object({
filter: z
.object({
id: z
.object({
in: z
.array(z.string().uuid())
.describe('Array of record IDs to delete'),
})
.describe('Filter to select records to delete'),
})
.describe('Filter criteria to select records for bulk delete'),
}),
filter: z
.object({
id: z
.object({
in: z
.array(z.string().uuid())
.describe('Array of record IDs to delete'),
})
.describe('Filter to select records to delete'),
})
.describe('Filter criteria to select records for bulk delete'),
});
export type BulkDeleteToolInput = z.infer<typeof BulkDeleteToolInputSchema>;
@@ -1,15 +1,7 @@
import { z } from 'zod';
export const FindOneToolInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: z.object({
id: z.string().uuid().describe('The unique UUID of the record to retrieve'),
}),
id: z.string().uuid().describe('The unique UUID of the record to retrieve'),
});
export type FindOneToolInput = z.infer<typeof FindOneToolInputSchema>;
@@ -62,41 +62,33 @@ export const generateFindToolInputSchema = (
);
return z.object({
loadingMessage: z
.string()
limit: z
.number()
.int()
.positive()
.max(1000)
.default(100)
.describe('Maximum number of records to return (default: 100)'),
offset: z
.number()
.int()
.nonnegative()
.default(0)
.describe('Number of records to skip (default: 0)'),
orderBy: ObjectRecordOrderBySchema.describe(
'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.',
),
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: z.object({
limit: z
.number()
.int()
.positive()
.max(1000)
.default(100)
.describe('Maximum number of records to return (default: 100)'),
offset: z
.number()
.int()
.nonnegative()
.default(0)
.describe('Number of records to skip (default: 0)'),
orderBy: ObjectRecordOrderBySchema.describe(
'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.',
),
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
}),
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
});
};
@@ -1,18 +1,10 @@
import { z } from 'zod';
export const SoftDeleteToolInputSchema = z.object({
loadingMessage: z
id: z
.string()
.optional()
.describe(
'A clear, human-readable description of the action being performed. Explain what operation you are executing and with what parameters in natural language.',
),
input: z.object({
id: z
.string()
.uuid()
.describe('The unique UUID of the record to soft delete'),
}),
.uuid()
.describe('The unique UUID of the record to soft delete'),
});
export type SoftDeleteToolInput = z.infer<typeof SoftDeleteToolInputSchema>;
@@ -6,4 +6,5 @@ export enum ToolCategory {
NATIVE_MODEL = 'NATIVE_MODEL',
VIEW = 'VIEW',
DASHBOARD = 'DASHBOARD',
SERVERLESS_FUNCTION = 'SERVERLESS_FUNCTION',
}
@@ -2,21 +2,36 @@ import { type ToolSet } from 'ai';
import { type CodeExecutionData } from 'twenty-shared/ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CodeExecutionStreamEmitter = (data: CodeExecutionData) => void;
// Unified context for tool generation - used by all consumers
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
// Optional fields for different use cases
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
agent?: FlatAgentWithRoleId | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
// Options for tool retrieval
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: ToolType[];
wrapWithErrorContext?: boolean;
};
export interface ToolProvider {
readonly category: ToolCategory;
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ZodObject, type ZodRawShape } from 'zod';
import {
type ToolProvider,
@@ -18,6 +19,10 @@ import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import {
stripLoadingMessage,
wrapSchemaForExecution,
} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
@@ -98,9 +103,11 @@ export class ActionToolProvider implements ToolProvider {
private createToolEntry(tool: Tool, context: ToolExecutionContext) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, context),
inputSchema: wrapSchemaForExecution(
tool.inputSchema as ZodObject<ZodRawShape>,
),
execute: async (parameters: ToolInput) =>
tool.execute(stripLoadingMessage(parameters), context),
};
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
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 { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Injectable()
export class NativeModelToolProvider implements ToolProvider {
readonly category = ToolCategory.NATIVE_MODEL;
constructor(
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return isDefined(context.agent);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
if (!context.agent) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
return this.agentModelConfigService.getNativeModelTools(
registeredModel,
context.agent,
);
}
}
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { jsonSchema, type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
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 { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
@Injectable()
export class ServerlessFunctionToolProvider implements ToolProvider {
readonly category = ToolCategory.SERVERLESS_FUNCTION;
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Serverless function tools are available if there are any functions marked as tools
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const { flatServerlessFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatServerlessFunctionMaps'],
},
);
// Filter serverless functions that are marked as tools
const serverlessFunctionsWithSchema = Object.values(
flatServerlessFunctionMaps.byId,
).filter(
(fn): fn is FlatServerlessFunction =>
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const tools: ToolSet = {};
for (const serverlessFunction of serverlessFunctionsWithSchema) {
const toolName = this.buildServerlessFunctionToolName(
serverlessFunction.name,
);
const wrappedSchema = wrapJsonSchemaForExecution(
serverlessFunction.toolInputSchema as Record<string, unknown>,
);
tools[toolName] = {
description:
serverlessFunction.description ||
`Execute the ${serverlessFunction.name} serverless function`,
inputSchema: jsonSchema(wrappedSchema),
execute: async (parameters: Record<string, unknown>) => {
const { loadingMessage: _, ...actualParams } = parameters;
const result =
await this.serverlessFunctionService.executeOneServerlessFunction({
id: serverlessFunction.id,
workspaceId: context.workspaceId,
payload: actualParams,
version: serverlessFunction.latestVersion ?? 'draft',
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
},
};
}
return tools;
}
private buildServerlessFunctionToolName(functionName: string): string {
// Convert function name to a valid tool name (lowercase, underscores)
return `serverless_${functionName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')}`;
}
}
@@ -0,0 +1,59 @@
import { UseGuards } from '@nestjs/common';
import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@ObjectType('ToolIndexEntry')
export class ToolIndexEntryDTO {
@Field()
name: string;
@Field()
description: string;
@Field()
category: string;
@Field({ nullable: true })
objectName?: string;
@Field(() => graphqlTypeJson, { nullable: true })
inputSchema?: object;
}
@Resolver()
@UseGuards(WorkspaceAuthGuard)
export class ToolIndexResolver {
constructor(
private readonly toolRegistryService: ToolRegistryService,
private readonly userRoleService: UserRoleService,
) {}
@Query(() => [ToolIndexEntryDTO])
@UseGuards(NoPermissionGuard)
async getToolIndex(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ToolIndexEntryDTO[]> {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId: workspace.id,
});
if (!roleId) {
return [];
}
return this.toolRegistryService.buildToolIndex(workspace.id, roleId, {
userWorkspaceId,
});
}
}
@@ -1,352 +0,0 @@
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolSpecification } from 'src/engine/core-modules/tool-provider/types/tool-specification.type';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
// Type-only import to avoid circular dependency at file level
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
type ActionTool = {
tool: Tool;
flag?: PermissionFlagType;
};
@Injectable()
export class ToolProviderService {
private readonly logger = new Logger(ToolProviderService.name);
private readonly actionTools: Map<ToolType, ActionTool>;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
// Optional to avoid circular dependency with WorkflowExecutorModule (null when called from workflow context)
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly permissionsService: PermissionsService,
) {
this.actionTools = new Map([
[
ToolType.HTTP_REQUEST,
{
tool: this.httpTool,
flag: PermissionFlagType.HTTP_REQUEST_TOOL,
},
],
[
ToolType.SEND_EMAIL,
{
tool: this.sendEmailTool,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
},
],
[
ToolType.SEARCH_HELP_CENTER,
{
tool: this.searchHelpCenterTool,
},
],
[
ToolType.CODE_INTERPRETER,
{
tool: this.codeInterpreterTool,
flag: PermissionFlagType.CODE_INTERPRETER_TOOL,
},
],
]);
}
getToolByType(toolType: ToolType): Tool {
const actionTool = this.actionTools.get(toolType);
if (!actionTool) {
throw new Error(`Unknown tool type: ${toolType}`);
}
return actionTool.tool;
}
async getTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
for (const category of spec.categories) {
const categoryTools = await this.getToolsForCategory(category, spec);
Object.assign(tools, categoryTools);
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${spec.categories.join(', ')}]`,
);
if (spec.wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return tools;
}
private async getToolsForCategory(
category: ToolCategory,
spec: ToolSpecification,
): Promise<ToolSet> {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return this.getDatabaseTools(spec);
case ToolCategory.ACTION:
return this.getActionTools(spec);
case ToolCategory.WORKFLOW:
return this.getWorkflowTools(spec);
case ToolCategory.METADATA:
return this.getMetadataTools(spec);
case ToolCategory.NATIVE_MODEL:
return this.getNativeModelTools(spec);
default:
return {};
}
}
private async getDatabaseTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.rolePermissionConfig || !spec.authContext) {
return {};
}
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
return this.perObjectToolGenerator.generate(
{
workspaceId: spec.workspaceId,
authContext: spec.authContext,
rolePermissionConfig: spec.rolePermissionConfig,
actorContext: spec.actorContext,
},
[factory],
);
}
private async getActionTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
const executionContext = { workspaceId: spec.workspaceId };
const excludedTools = new Set(spec.excludeTools ?? []);
for (const [toolType, { tool, flag }] of this.actionTools) {
if (excludedTools.has(toolType)) {
continue;
}
if (!flag) {
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, executionContext),
};
} else if (spec.rolePermissionConfig && spec.workspaceId) {
const hasPermission = await this.permissionsService.hasToolPermission(
spec.rolePermissionConfig,
spec.workspaceId,
flag,
);
if (hasPermission) {
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, executionContext),
};
}
}
}
return tools;
}
private async getWorkflowTools(spec: ToolSpecification): Promise<ToolSet> {
if (!this.workflowToolService) {
return {};
}
if (!spec.rolePermissionConfig) {
return {};
}
const hasWorkflowPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (!hasWorkflowPermission) {
return {};
}
const workflowTools = this.workflowToolService.generateWorkflowTools(
spec.workspaceId,
spec.rolePermissionConfig,
);
const recordStepTools =
await this.workflowToolService.generateRecordStepConfiguratorTools(
spec.workspaceId,
spec.rolePermissionConfig,
);
return { ...workflowTools, ...recordStepTools };
}
private async getMetadataTools(spec: ToolSpecification): Promise<ToolSet> {
if (spec.rolePermissionConfig) {
const hasDataModelPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.DATA_MODEL,
);
if (!hasDataModelPermission) {
return {};
}
}
const objectMetadataTools = this.objectMetadataToolsFactory.generateTools(
spec.workspaceId,
);
const fieldMetadataTools = this.fieldMetadataToolsFactory.generateTools(
spec.workspaceId,
);
return { ...objectMetadataTools, ...fieldMetadataTools };
}
private async getNativeModelTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.agent) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(spec.agent);
return this.agentModelConfigService.getNativeModelTools(
registeredModel,
spec.agent,
);
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -1,12 +1,14 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ToolSet, zodSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ZodType } from 'zod';
import {
type CodeExecutionStreamEmitter,
type ToolProvider,
type ToolProviderContext,
type ToolRetrievalOptions,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
@@ -17,25 +19,28 @@ export type ToolIndexEntry = {
name: string;
description: string;
category:
| 'database'
| 'action'
| 'workflow'
| 'metadata'
| 'view'
| 'dashboard';
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'SERVERLESS_FUNCTION';
objectName?: string;
operation?: string;
inputSchema?: object;
};
export type ToolSearchOptions = {
limit?: number;
category?:
| 'database'
| 'action'
| 'workflow'
| 'metadata'
| 'view'
| 'dashboard';
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'SERVERLESS_FUNCTION';
};
export type ToolContext = {
@@ -183,6 +188,44 @@ export class ToolRegistryService {
);
}
// Main method for eager loading tools by categories (replaces ToolProviderService.getTools)
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const tools: ToolSet = {};
for (const provider of this.providers) {
if (categories && !categories.includes(provider.category)) {
continue;
}
if (await provider.isAvailable(context)) {
const providerTools = await provider.generateTools(context);
Object.assign(tools, providerTools);
}
}
// Apply excludeTools filter
if (excludeTools?.length) {
for (const toolType of excludeTools) {
delete tools[toolType.toLowerCase()];
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
);
// Apply error wrapping if requested
if (wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return tools;
}
private buildContext(
workspaceId: string,
roleId: string,
@@ -209,19 +252,176 @@ export class ToolRegistryService {
category: ToolCategory,
): ToolIndexEntry[] {
const categoryMap: Record<ToolCategory, ToolIndexEntry['category']> = {
DATABASE_CRUD: 'database',
ACTION: 'action',
WORKFLOW: 'workflow',
METADATA: 'metadata',
NATIVE_MODEL: 'action',
VIEW: 'view',
DASHBOARD: 'dashboard',
DATABASE_CRUD: 'DATABASE',
ACTION: 'ACTION',
WORKFLOW: 'WORKFLOW',
METADATA: 'METADATA',
NATIVE_MODEL: 'ACTION',
VIEW: 'VIEW',
DASHBOARD: 'DASHBOARD',
SERVERLESS_FUNCTION: 'SERVERLESS_FUNCTION',
};
return Object.entries(tools).map(([name, tool]) => ({
name,
description: tool.description ?? '',
category: categoryMap[category],
}));
return Object.entries(tools).map(([name, tool]) => {
const inputSchema = this.extractJsonSchema(tool.inputSchema);
return {
name,
description: tool.description ?? '',
category: categoryMap[category],
inputSchema,
};
});
}
private extractJsonSchema(inputSchema: unknown): object | undefined {
if (!inputSchema) {
return undefined;
}
let schema: object | undefined;
// Check if it's a Zod schema (has _def property)
if (
typeof inputSchema === 'object' &&
inputSchema !== null &&
'_def' in inputSchema
) {
try {
// Use AI SDK's zodSchema() to convert Zod to JSON Schema
const converted = zodSchema(inputSchema as ZodType);
schema = converted.jsonSchema as object;
} catch {
// If conversion fails, return undefined
return undefined;
}
} else if (
// Check if AI SDK wrapped it with jsonSchema property
typeof inputSchema === 'object' &&
inputSchema !== null &&
'jsonSchema' in inputSchema
) {
schema = (inputSchema as { jsonSchema: object }).jsonSchema;
} else if (typeof inputSchema === 'object') {
// Return as-is if it's already an object (plain JSON schema)
schema = inputSchema as object;
}
if (!schema) {
return undefined;
}
return this.stripInternalFieldsFromSchema(schema);
}
// Remove internal fields (loadingMessage) from schema for display
private stripInternalFieldsFromSchema(schema: object): object {
const schemaObj = schema as Record<string, unknown>;
// Remove $schema property
const { $schema: _, ...rest } = schemaObj;
// Remove loadingMessage from properties if present
// loadingMessage is an internal field auto-injected for AI status updates
if (
rest.type === 'object' &&
rest.properties &&
typeof rest.properties === 'object'
) {
const properties = rest.properties as Record<string, unknown>;
const { loadingMessage: __, ...cleanProperties } = properties;
// Filter required array to remove loadingMessage if present
const required = Array.isArray(rest.required)
? rest.required.filter((field) => field !== 'loadingMessage')
: undefined;
return {
...rest,
properties: cleanProperties,
...(required && required.length > 0 ? { required } : {}),
};
}
return rest;
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
_toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -7,6 +7,8 @@ import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/provid
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { ServerlessFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/serverless-function-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
@@ -16,10 +18,12 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ToolProviderService } from './services/tool-provider.service';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
// NOTE: This module does NOT import WorkflowToolsModule or DashboardToolsModule to avoid
@@ -40,12 +44,17 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ServerlessFunctionModule,
UserRoleModule,
],
providers: [
ToolIndexResolver,
ActionToolProvider,
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
ServerlessFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
{
@@ -55,6 +64,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider: DashboardToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
nativeModelProvider: NativeModelToolProvider,
serverlessFunctionProvider: ServerlessFunctionToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
@@ -62,6 +73,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider,
databaseProvider,
metadataProvider,
nativeModelProvider,
serverlessFunctionProvider,
viewProvider,
workflowProvider,
],
@@ -70,13 +83,14 @@ import { ToolRegistryService } from './services/tool-registry.service';
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
ServerlessFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
],
},
ToolProviderService,
ToolRegistryService,
],
exports: [ToolProviderService, ToolRegistryService],
exports: [ToolRegistryService],
})
export class ToolProviderModule {}
@@ -5,16 +5,14 @@ import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/fla
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
export const loadSkillInputSchema = z.object({
input: z.object({
skillNames: z
.array(z.string())
.describe(
'Names of the skills to load (e.g., ["workflow-building", "data-manipulation"])',
),
}),
skillNames: z
.array(z.string())
.describe(
'Names of the skills to load (e.g., ["workflow-building", "data-manipulation"])',
),
});
export type LoadSkillInput = z.infer<typeof loadSkillInputSchema>['input'];
export type LoadSkillInput = z.infer<typeof loadSkillInputSchema>;
export type LoadSkillResult = {
skills: Array<{
@@ -31,10 +29,8 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
description:
'Load specialized skills/expertise by name. Returns detailed instructions for workflows, data manipulation, dashboards, metadata, or research.',
inputSchema: loadSkillInputSchema,
execute: async (parameters: {
input: LoadSkillInput;
}): Promise<LoadSkillResult> => {
const { skillNames } = parameters.input;
execute: async (parameters: LoadSkillInput): Promise<LoadSkillResult> => {
const { skillNames } = parameters;
const skills = await loadSkills(skillNames);
@@ -8,16 +8,14 @@ import {
export const LOAD_TOOLS_TOOL_NAME = 'load_tools' as const;
export const loadToolsInputSchema = z.object({
input: z.object({
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
}),
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
});
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>['input'];
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>;
export type LoadToolsResult = {
loaded: string[];
@@ -37,10 +35,8 @@ export const createLoadToolsTool = (
) => ({
description: `Load tools by name to make them available for use. Call this when you need to use a tool from the catalog that isn't already loaded. You can load multiple tools at once.`,
inputSchema: loadToolsInputSchema,
execute: async (parameters: {
input: LoadToolsInput;
}): Promise<LoadToolsResult> => {
const { toolNames } = parameters.input;
execute: async (parameters: LoadToolsInput): Promise<LoadToolsResult> => {
const { toolNames } = parameters;
const loaded: string[] = [];
const notFound: string[] = [];
@@ -1,20 +0,0 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolSpecification = {
workspaceId: string;
categories: ToolCategory[];
rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
agent?: FlatAgentWithRoleId | null;
wrapWithErrorContext?: boolean;
// Tools to exclude from the generated toolset (security: prevent recursive code execution)
excludeTools?: ToolType[];
};
@@ -14,12 +14,3 @@ export const CodeInterpreterInputZodSchema = z.object({
.optional()
.describe('Files to make available in the execution environment'),
});
export const CodeInterpreterToolParametersZodSchema = z.object({
loadingMessage: z
.string()
.describe(
"A clear, human-readable status message describing the code being executed. This will be shown to the user while the tool is running, so phrase it as a present-tense status update (e.g., 'Creating a bar chart from sales data'). Explain what analysis or visualization you are performing in natural language.",
),
input: CodeInterpreterInputZodSchema,
});
@@ -24,7 +24,7 @@ import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
import { JwtWrapperService } from 'src/engine/core-modules/jwt/services/jwt-wrapper.service';
import { CodeInterpreterToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
import { CodeInterpreterInputZodSchema } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool.schema';
import { TWENTY_MCP_HELPER } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const';
import { type CodeInterpreterInput } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/types/code-interpreter-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
@@ -44,7 +44,7 @@ export class CodeInterpreterTool implements Tool {
description =
'Execute Python code in a sandboxed environment for data analysis, CSV processing, calculations, and chart generation. Returns stdout, stderr, and generated files. Input files are available at /home/user/{filename}. Save output files (charts, reports) to /home/user/output/ using plt.savefig() for matplotlib charts.';
inputSchema = CodeInterpreterToolParametersZodSchema;
inputSchema = CodeInterpreterInputZodSchema;
constructor(
private readonly codeInterpreterService: CodeInterpreterService,
@@ -14,12 +14,3 @@ export const HttpRequestInputZodSchema = z.object({
.optional()
.describe('Request body for POST, PUT, PATCH requests'),
});
export const HttpToolParametersZodSchema = z.object({
loadingMessage: z
.string()
.describe(
"A clear, human-readable status message describing the HTTP request being made. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Making a GET request to ...'). Explain what endpoint you are calling and with what parameters in natural language.",
),
input: HttpRequestInputZodSchema,
});
@@ -4,7 +4,7 @@ import axios, { type AxiosRequestConfig } from 'axios';
import { isDefined } from 'twenty-shared/utils';
import { parseDataFromContentType } from 'twenty-shared/workflow';
import { HttpToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/http-tool/http-tool.schema';
import { HttpRequestInputZodSchema } from 'src/engine/core-modules/tool/tools/http-tool/http-tool.schema';
import { type HttpRequestInput } from 'src/engine/core-modules/tool/tools/http-tool/types/http-request-input.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@@ -19,7 +19,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
export class HttpTool implements Tool {
description =
'Make an HTTP request to any URL with configurable method, headers, and body.';
inputSchema = HttpToolParametersZodSchema;
inputSchema = HttpRequestInputZodSchema;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -6,15 +6,6 @@ export const SearchHelpCenterInputZodSchema = z.object({
.describe('The search query to find relevant help articles about Twenty'),
});
export const SearchHelpCenterToolParametersZodSchema = z.object({
loadingMessage: z
.string()
.describe(
'A clear, human-readable status message describing the search being performed. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., "Searching help center for..."). Explain what you are searching for in natural language.',
),
input: SearchHelpCenterInputZodSchema,
});
export type SearchHelpCenterInput = z.infer<
typeof SearchHelpCenterInputZodSchema
>;
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { SearchHelpCenterToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema';
import { SearchHelpCenterInputZodSchema } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool.schema';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
@@ -15,7 +15,7 @@ import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twent
export class SearchHelpCenterTool implements Tool {
description =
'Search Twenty documentation and help center to find information about features, setup, usage, and troubleshooting.';
inputSchema = SearchHelpCenterToolParametersZodSchema;
inputSchema = SearchHelpCenterInputZodSchema;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -1,6 +1,6 @@
import { isValidUuid } from 'twenty-shared/utils';
import { z } from 'zod';
import { workflowFileSchema } from 'twenty-shared/workflow';
import { z } from 'zod';
export const SendEmailInputZodSchema = z.object({
email: z.email().describe('The recipient email address'),
@@ -19,12 +19,3 @@ export const SendEmailInputZodSchema = z.object({
.optional()
.default([]),
});
export const SendEmailToolParametersZodSchema = z.object({
loadingMessage: z
.string()
.describe(
"A clear, human-readable status message describing the email being sent. This will be shown to the user while the tool is being called, so phrase it as a present-tense status update (e.g., 'Sending email to customer about order status'). Explain what email you are sending and to whom in natural language.",
),
input: SendEmailInputZodSchema,
});
@@ -15,7 +15,7 @@ import {
SendEmailToolException,
SendEmailToolExceptionCode,
} from 'src/engine/core-modules/tool/tools/send-email-tool/exceptions/send-email-tool.exception';
import { SendEmailToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { SendEmailInputZodSchema } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool.schema';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import {
@@ -36,7 +36,7 @@ export class SendEmailTool implements Tool {
description =
'Send an email using a connected account. Requires SEND_EMAIL_TOOL permission.';
inputSchema = SendEmailToolParametersZodSchema;
inputSchema = SendEmailInputZodSchema;
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@@ -0,0 +1,48 @@
import { z } from 'zod';
const DEFAULT_LOADING_MESSAGE_SCHEMA = z
.string()
.describe(
"A brief status message for the user describing what you're doing (e.g., 'Sending email to customer').",
);
// Wraps a flat Zod tool schema with loadingMessage for AI execution
export const wrapSchemaForExecution = <T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
customLoadingMessageSchema?: z.ZodString,
): z.ZodObject<T & { loadingMessage: z.ZodString }> => {
return z.object({
loadingMessage:
customLoadingMessageSchema ?? DEFAULT_LOADING_MESSAGE_SCHEMA,
...schema.shape,
}) as z.ZodObject<T & { loadingMessage: z.ZodString }>;
};
// For non-Zod schemas (serverless functions with JSON Schema)
export const wrapJsonSchemaForExecution = (
schema: Record<string, unknown>,
): Record<string, unknown> => {
const properties = (schema.properties as Record<string, unknown>) ?? {};
const required = (schema.required as string[]) ?? [];
return {
type: 'object',
properties: {
loadingMessage: {
type: 'string',
description: 'A brief status message for the user.',
},
...properties,
},
required: ['loadingMessage', ...required],
};
};
// Strips loadingMessage from parameters before passing to tool execute
export const stripLoadingMessage = <T extends Record<string, unknown>>(
parameters: T,
): Omit<T, 'loadingMessage'> => {
const { loadingMessage: _, ...rest } = parameters;
return rest;
};