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
@@ -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[];
};