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:
+1
@@ -6,4 +6,5 @@ export enum ToolCategory {
|
||||
NATIVE_MODEL = 'NATIVE_MODEL',
|
||||
VIEW = 'VIEW',
|
||||
DASHBOARD = 'DASHBOARD',
|
||||
SERVERLESS_FUNCTION = 'SERVERLESS_FUNCTION',
|
||||
}
|
||||
|
||||
+15
@@ -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;
|
||||
|
||||
|
||||
+10
-3
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+41
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+100
@@ -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, '')}`;
|
||||
}
|
||||
}
|
||||
+59
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
-352
@@ -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';
|
||||
}
|
||||
}
|
||||
+225
-25
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
+17
-3
@@ -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 {}
|
||||
|
||||
+8
-12
@@ -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
-12
@@ -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[] = [];
|
||||
|
||||
-20
@@ -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[];
|
||||
};
|
||||
Reference in New Issue
Block a user