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
@@ -120,7 +120,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_views'], {
input: { limit: 50 },
limit: 50,
});
expect(viewService.findByWorkspaceId).toHaveBeenCalledWith(
@@ -150,7 +150,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_views'], {
input: { objectNameSingular: mockObjectNameSingular, limit: 50 },
objectNameSingular: mockObjectNameSingular,
limit: 50,
});
expect(viewService.findByObjectMetadataId).toHaveBeenCalledWith(
@@ -173,7 +174,7 @@ describe('ViewToolsFactory', () => {
const tools = viewToolsFactory.generateReadTools(mockWorkspaceId);
const result = await callExecute(tools['get_views'], {
input: { limit: 2 },
limit: 2,
});
expect(result).toHaveLength(2);
@@ -201,7 +202,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['get_view_query_parameters'], {
input: { viewId: mockViewId },
viewId: mockViewId,
});
expect(
@@ -244,11 +245,9 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['create_view'], {
input: {
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
},
name: 'New View',
objectNameSingular: mockObjectNameSingular,
icon: 'IconTable',
});
expect(viewService.createOne).toHaveBeenCalledWith({
@@ -293,10 +292,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
});
expect(viewService.updateOne).toHaveBeenCalled();
@@ -323,10 +320,8 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
});
expect(result.name).toBe('Updated Name');
@@ -348,10 +343,8 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['update_view'], {
input: {
id: mockViewId,
name: 'Updated Name',
},
id: mockViewId,
name: 'Updated Name',
}),
).rejects.toThrow('You can only update your own unlisted views');
});
@@ -363,10 +356,8 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['update_view'], {
input: {
id: 'non-existent-id',
name: 'Updated Name',
},
id: 'non-existent-id',
name: 'Updated Name',
}),
).rejects.toThrow('View with id non-existent-id not found');
});
@@ -392,7 +383,7 @@ describe('ViewToolsFactory', () => {
);
const result = await callExecute(tools['delete_view'], {
input: { id: mockViewId },
id: mockViewId,
});
expect(viewService.deleteOne).toHaveBeenCalledWith({
@@ -422,7 +413,7 @@ describe('ViewToolsFactory', () => {
await expect(
callExecute(tools['delete_view'], {
input: { id: mockViewId },
id: mockViewId,
}),
).rejects.toThrow('You can only delete your own unlisted views');
});
@@ -12,90 +12,60 @@ import { ViewService } from 'src/engine/metadata-modules/view/services/view.serv
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetViewsInputSchema = z.object({
loadingMessage: z
objectNameSingular: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
objectNameSingular: z
.string()
.optional()
.describe(
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe('Maximum views to return.'),
}),
.describe(
'Filter views by object name (e.g., "task", "person", "company"). If omitted, returns all views.',
),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(50)
.describe('Maximum views to return.'),
});
const GetViewQueryParamsInputSchema = z.object({
loadingMessage: z
viewId: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
viewId: z
.string()
.uuid()
.describe('ID of the view to get query parameters for.'),
}),
.uuid()
.describe('ID of the view to get query parameters for.'),
});
const CreateViewInputSchema = z.object({
loadingMessage: z
name: z.string().describe('View name'),
objectNameSingular: z
.string()
.describe(
'Object name this view is for (e.g., "task", "person", "company")',
),
icon: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
name: z.string().describe('View name'),
objectNameSingular: z
.string()
.describe(
'Object name this view is for (e.g., "task", "person", "company")',
),
icon: z
.string()
.optional()
.default('IconList')
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.default(ViewType.TABLE)
.describe('View type'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
}),
.default('IconList')
.describe('Icon identifier (e.g., "IconList", "IconCheckbox")'),
type: z
.enum([ViewType.TABLE, ViewType.KANBAN, ViewType.CALENDAR])
.optional()
.default(ViewType.TABLE)
.describe('View type'),
visibility: z
.enum([ViewVisibility.WORKSPACE, ViewVisibility.UNLISTED])
.optional()
.default(ViewVisibility.WORKSPACE)
.describe('View visibility'),
});
const UpdateViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to update'),
name: z.string().optional().describe('New view name'),
icon: z.string().optional().describe('New icon identifier'),
}),
id: z.string().uuid().describe('View ID to update'),
name: z.string().optional().describe('New view name'),
icon: z.string().optional().describe('New icon identifier'),
});
const DeleteViewInputSchema = z.object({
loadingMessage: z
.string()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('View ID to delete'),
}),
id: z.string().uuid().describe('View ID to delete'),
});
@Injectable()
@@ -142,14 +112,15 @@ export class ViewToolsFactory {
'List views in the workspace. Views define how records are displayed, filtered, and sorted.',
inputSchema: GetViewsInputSchema,
execute: async (parameters: {
input: { objectNameSingular?: string; limit?: number };
objectNameSingular?: string;
limit?: number;
}) => {
let views;
if (parameters.input.objectNameSingular) {
if (parameters.objectNameSingular) {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
parameters.objectNameSingular,
);
views = await this.viewService.findByObjectMetadataId(
@@ -164,7 +135,7 @@ export class ViewToolsFactory {
);
}
const limitedViews = views.slice(0, parameters.input.limit ?? 50);
const limitedViews = views.slice(0, parameters.limit ?? 50);
return limitedViews.map((view) => ({
id: view.id,
@@ -181,9 +152,9 @@ export class ViewToolsFactory {
description:
'Get filter and sort parameters from a view. Use these parameters with find_* tools to query records matching the view.',
inputSchema: GetViewQueryParamsInputSchema,
execute: async (parameters: { input: { viewId: string } }) => {
execute: async (parameters: { viewId: string }) => {
return this.viewQueryParamsService.resolveViewToQueryParams(
parameters.input.viewId,
parameters.viewId,
workspaceId,
currentWorkspaceMemberId,
);
@@ -199,28 +170,25 @@ export class ViewToolsFactory {
'Create a new view for an object. Views define how records are displayed.',
inputSchema: CreateViewInputSchema,
execute: async (parameters: {
input: {
name: string;
objectNameSingular: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
};
name: string;
objectNameSingular: string;
icon?: string;
type?: ViewType;
visibility?: ViewVisibility;
}) => {
try {
const objectMetadataId = await this.resolveObjectMetadataId(
workspaceId,
parameters.input.objectNameSingular,
parameters.objectNameSingular,
);
const view = await this.viewService.createOne({
createViewInput: {
name: parameters.input.name,
name: parameters.name,
objectMetadataId,
icon: parameters.input.icon ?? 'IconList',
type: parameters.input.type ?? ViewType.TABLE,
visibility:
parameters.input.visibility ?? ViewVisibility.WORKSPACE,
icon: parameters.icon ?? 'IconList',
type: parameters.type ?? ViewType.TABLE,
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
},
workspaceId,
createdByUserWorkspaceId: userWorkspaceId,
@@ -229,7 +197,7 @@ export class ViewToolsFactory {
return {
id: view.id,
name: view.name,
objectNameSingular: parameters.input.objectNameSingular,
objectNameSingular: parameters.objectNameSingular,
type: view.type,
icon: view.icon,
visibility: view.visibility,
@@ -247,20 +215,18 @@ export class ViewToolsFactory {
'Update an existing view. You can change the name and icon.',
inputSchema: UpdateViewInputSchema,
execute: async (parameters: {
input: {
id: string;
name?: string;
icon?: string;
};
id: string;
name?: string;
icon?: string;
}) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
parameters.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
throw new Error(`View with id ${parameters.id} not found`);
}
if (
@@ -272,9 +238,9 @@ export class ViewToolsFactory {
const view = await this.viewService.updateOne({
updateViewInput: {
id: parameters.input.id,
name: parameters.input.name,
icon: parameters.input.icon,
id: parameters.id,
name: parameters.name,
icon: parameters.icon,
},
workspaceId,
userWorkspaceId,
@@ -299,15 +265,15 @@ export class ViewToolsFactory {
delete_view: {
description: 'Delete a view by its ID.',
inputSchema: DeleteViewInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const existingView = await this.viewService.findById(
parameters.input.id,
parameters.id,
workspaceId,
);
if (!existingView) {
throw new Error(`View with id ${parameters.input.id} not found`);
throw new Error(`View with id ${parameters.id} not found`);
}
if (
@@ -318,7 +284,7 @@ export class ViewToolsFactory {
}
const view = await this.viewService.deleteOne({
deleteViewInput: { id: parameters.input.id },
deleteViewInput: { id: parameters.id },
workspaceId,
});