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:
@@ -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 =
|
||||
|
||||
+13
-5
@@ -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
-12
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+1
-11
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+11
-19
@@ -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
-9
@@ -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>;
|
||||
|
||||
+27
-35
@@ -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'),
|
||||
});
|
||||
};
|
||||
|
||||
+3
-11
@@ -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>;
|
||||
|
||||
+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[];
|
||||
};
|
||||
-9
@@ -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,
|
||||
});
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
-9
@@ -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) {}
|
||||
|
||||
|
||||
-9
@@ -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
-2
@@ -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
-10
@@ -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,
|
||||
});
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+48
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user