feat: Serverless Functions as AI Tools (#16919)

## Summary

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

### Changes

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

### Implementation Details

- Serverless functions can now define tool metadata (description,
input/output schemas)
- These functions are automatically registered in the tool registry
- The tool index endpoint allows querying available tools with their
schemas
- Settings page now has a dedicated Tools tab showing all available
tools
This commit is contained in:
Félix Malfait
2026-01-05 11:13:06 +01:00
committed by GitHub
parent c0d71f7f96
commit 0173e40a20
76 changed files with 2137 additions and 1304 deletions
@@ -9,112 +9,76 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetObjectMetadataInputSchema = z.object({
loadingMessage: z
id: z
.string()
.uuid()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z
.string()
.uuid()
.optional()
.describe(
'Unique identifier for the object metadata. If provided, returns a single object.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of objects to return.'),
}),
.describe(
'Unique identifier for the object metadata. If provided, returns a single object.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of objects to return.'),
});
const CreateObjectMetadataInputSchema = z.object({
loadingMessage: z
nameSingular: z
.string()
.describe('Singular name for the object (e.g., "company")'),
namePlural: z
.string()
.describe('Plural name for the object (e.g., "companies")'),
labelSingular: z
.string()
.describe('Display label in singular form (e.g., "Company")'),
labelPlural: z
.string()
.describe('Display label in plural form (e.g., "Companies")'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isRemote: z.boolean().optional().describe('Whether this is a remote object'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
nameSingular: z
.string()
.describe('Singular name for the object (e.g., "company")'),
namePlural: z
.string()
.describe('Plural name for the object (e.g., "companies")'),
labelSingular: z
.string()
.describe('Display label in singular form (e.g., "Company")'),
labelPlural: z
.string()
.describe('Display label in plural form (e.g., "Companies")'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z
.string()
.optional()
.describe('Keyboard shortcut for the object'),
isRemote: z
.boolean()
.optional()
.describe('Whether this is a remote object'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Whether label should sync with name changes'),
});
const UpdateObjectMetadataInputSchema = z.object({
loadingMessage: z
id: z.string().uuid().describe('ID of the object to update'),
labelSingular: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the object to update'),
labelSingular: z
.string()
.optional()
.describe('Display label in singular form'),
labelPlural: z.string().optional().describe('Display label in plural form'),
nameSingular: z
.string()
.optional()
.describe('Singular name for the object'),
namePlural: z.string().optional().describe('Plural name for the object'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z
.string()
.optional()
.describe('Keyboard shortcut for the object'),
isActive: z.boolean().optional().describe('Whether the object is active'),
labelIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as label identifier'),
imageIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as image identifier'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Display label in singular form'),
labelPlural: z.string().optional().describe('Display label in plural form'),
nameSingular: z.string().optional().describe('Singular name for the object'),
namePlural: z.string().optional().describe('Plural name for the object'),
description: z.string().optional().describe('Description of the object'),
icon: z.string().optional().describe('Icon identifier for the object'),
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
isActive: z.boolean().optional().describe('Whether the object is active'),
labelIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as label identifier'),
imageIdentifierFieldMetadataId: z
.string()
.uuid()
.optional()
.describe('ID of the field used as image identifier'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
});
const DeleteObjectMetadataInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the object to delete'),
}),
id: z.string().uuid().describe('ID of the object to delete'),
});
@Injectable()
@@ -127,17 +91,13 @@ export class ObjectMetadataToolsFactory {
description:
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
inputSchema: GetObjectMetadataInputSchema,
execute: async (parameters: {
input: { id?: string; limit?: number };
}) => {
execute: async (parameters: { id?: string; limit?: number }) => {
const flatObjectMetadatas =
await this.objectMetadataService.findManyWithinWorkspace(
workspaceId,
{
...(parameters.input.id
? { where: { id: parameters.input.id } }
: {}),
take: parameters.input.limit ?? 100,
...(parameters.id ? { where: { id: parameters.id } } : {}),
take: parameters.limit ?? 100,
},
);
@@ -151,22 +111,20 @@ export class ObjectMetadataToolsFactory {
'Create a new object metadata in the workspace data model.',
inputSchema: CreateObjectMetadataInputSchema,
execute: async (parameters: {
input: {
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
};
nameSingular: string;
namePlural: string;
labelSingular: string;
labelPlural: string;
description?: string;
icon?: string;
shortcut?: string;
isRemote?: boolean;
isLabelSyncedWithName?: boolean;
}) => {
try {
const flatObjectMetadata =
await this.objectMetadataService.createOneObject({
createObjectInput: parameters.input as Parameters<
createObjectInput: parameters as Parameters<
typeof this.objectMetadataService.createOneObject
>[0]['createObjectInput'],
workspaceId,
@@ -188,23 +146,21 @@ export class ObjectMetadataToolsFactory {
'Update an existing object metadata. Provide the object ID and the fields to update.',
inputSchema: UpdateObjectMetadataInputSchema,
execute: async (parameters: {
input: {
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
};
id: string;
labelSingular?: string;
labelPlural?: string;
nameSingular?: string;
namePlural?: string;
description?: string;
icon?: string;
shortcut?: string;
isActive?: boolean;
labelIdentifierFieldMetadataId?: string;
imageIdentifierFieldMetadataId?: string;
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters.input;
const { id, ...update } = parameters;
const flatObjectMetadata =
await this.objectMetadataService.updateOneObject({
@@ -227,11 +183,11 @@ export class ObjectMetadataToolsFactory {
description:
'Delete an object metadata by its ID. This will also delete all associated fields.',
inputSchema: DeleteObjectMetadataInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const flatObjectMetadata =
await this.objectMetadataService.deleteOneObject({
deleteObjectInput: { id: parameters.input.id },
deleteObjectInput: { id: parameters.id },
workspaceId,
});