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
@@ -968,8 +968,10 @@ export type CreateServerlessFunctionInput = {
description?: InputMaybe<Scalars['String']>;
handlerName?: InputMaybe<Scalars['String']>;
handlerPath?: InputMaybe<Scalars['String']>;
isTool?: InputMaybe<Scalars['Boolean']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type CreateSkillInput = {
@@ -3422,6 +3424,7 @@ export type Query = {
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
getTimelineThreadsFromOpportunityId: TimelineThreadsWithTotal;
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
getToolIndex: Array<ToolIndexEntry>;
index: Index;
indexMetadatas: IndexConnection;
listPlans: Array<BillingPlanOutput>;
@@ -4103,6 +4106,7 @@ export type Sentry = {
export type ServerlessFunction = {
__typename?: 'ServerlessFunction';
applicationId?: Maybe<Scalars['UUID']>;
createdAt: Scalars['DateTime'];
cronTriggers?: Maybe<Array<CronTrigger>>;
databaseEventTriggers?: Maybe<Array<DatabaseEventTrigger>>;
@@ -4110,12 +4114,14 @@ export type ServerlessFunction = {
handlerName: Scalars['String'];
handlerPath: Scalars['String'];
id: Scalars['UUID'];
isTool: Scalars['Boolean'];
latestVersion?: Maybe<Scalars['String']>;
name: Scalars['String'];
publishedVersions: Array<Scalars['String']>;
routeTriggers?: Maybe<Array<RouteTrigger>>;
runtime: Scalars['String'];
timeoutSeconds: Scalars['Float'];
toolInputSchema?: Maybe<Scalars['JSON']>;
updatedAt: Scalars['DateTime'];
};
@@ -4413,6 +4419,15 @@ export type TimelineThreadsWithTotal = {
totalNumberOfThreads: Scalars['Int'];
};
export type ToolIndexEntry = {
__typename?: 'ToolIndexEntry';
category: Scalars['String'];
description: Scalars['String'];
inputSchema?: Maybe<Scalars['JSON']>;
name: Scalars['String'];
objectName?: Maybe<Scalars['String']>;
};
export type TransientTokenOutput = {
__typename?: 'TransientTokenOutput';
transientToken: AuthToken;
@@ -4660,8 +4675,10 @@ export type UpdateServerlessFunctionInputUpdates = {
description?: InputMaybe<Scalars['String']>;
handlerName?: InputMaybe<Scalars['String']>;
handlerPath?: InputMaybe<Scalars['String']>;
isTool?: InputMaybe<Scalars['Boolean']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type UpdateSkillInput = {
@@ -5427,6 +5444,11 @@ export type GetChatThreadsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetChatThreadsQuery = { __typename?: 'Query', chatThreads: Array<{ __typename?: 'AgentChatThread', id: string, title?: string | null, totalInputTokens: number, totalOutputTokens: number, contextWindowTokens?: number | null, totalInputCredits: number, totalOutputCredits: number, createdAt: string, updatedAt: string }> };
export type GetToolIndexQueryVariables = Exact<{ [key: string]: never; }>;
export type GetToolIndexQuery = { __typename?: 'Query', getToolIndex: Array<{ __typename?: 'ToolIndexEntry', name: string, description: string, category: string, objectName?: string | null, inputSchema?: any | null }> };
export type TrackAnalyticsMutationVariables = Exact<{
type: AnalyticsType;
event?: InputMaybe<Scalars['String']>;
@@ -5446,7 +5468,7 @@ export type UpdateOneApplicationVariableMutationVariables = Exact<{
export type UpdateOneApplicationVariableMutation = { __typename?: 'Mutation', updateOneApplicationVariable: boolean };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
export type ApplicationFieldsFragment = { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
export type FindManyApplicationsQueryVariables = Exact<{ [key: string]: never; }>;
@@ -5458,7 +5480,7 @@ export type FindOneApplicationQueryVariables = Exact<{
}>;
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
export type FindOneApplicationQuery = { __typename?: 'Query', findOneApplication: { __typename?: 'Application', id: string, name: string, description: string, version: string, universalIdentifier: string, canBeUninstalled: boolean, applicationVariables: Array<{ __typename?: 'ApplicationVariable', id: string, key: string, value: string, description: string, isSecret: boolean }>, agents: Array<{ __typename?: 'Agent', id: string, name: string, label: string, description?: string | null, icon?: string | null, prompt: string, modelId: string, responseFormat?: any | null, roleId?: string | null, isCustom: boolean, modelConfiguration?: any | null, evaluationInputs: Array<string>, applicationId?: string | null, createdAt: string, updatedAt: string }>, objects: Array<{ __typename?: 'Object', id: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, isUIReadOnly: boolean, createdAt: string, updatedAt: string, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, applicationId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, isSearchable: boolean, duplicateCriteria?: Array<Array<string>> | null, indexMetadataList: Array<{ __typename?: 'Index', id: string, createdAt: string, updatedAt: string, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, isCustom?: boolean | null, indexFieldMetadataList: Array<{ __typename?: 'IndexField', id: string, fieldMetadataId: string, createdAt: string, updatedAt: string, order: number }> }>, fieldsList: Array<{ __typename?: 'Field', id: string, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isUIReadOnly?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: string, updatedAt: string, defaultValue?: any | null, options?: any | null, settings?: any | null, isLabelSyncedWithName?: boolean | null, applicationId?: string | null, relation?: { __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } } | null, morphRelations?: Array<{ __typename?: 'Relation', type: RelationType, sourceObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, targetObjectMetadata: { __typename?: 'Object', id: string, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'Field', id: string, name: string }, targetFieldMetadata: { __typename?: 'Field', id: string, name: string } }> | null }> }>, serverlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> } };
export type UploadFileMutationVariables = Exact<{
file: Scalars['Upload'];
@@ -6306,21 +6328,21 @@ export type GetSsoIdentityProvidersQueryVariables = Exact<{ [key: string]: never
export type GetSsoIdentityProvidersQuery = { __typename?: 'Query', getSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdentityProviderType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> };
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null };
export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null };
export type CreateOneServerlessFunctionItemMutationVariables = Exact<{
input: CreateServerlessFunctionInput;
}>;
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type DeleteOneServerlessFunctionMutationVariables = Exact<{
input: ServerlessFunctionIdInput;
}>;
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type ExecuteOneServerlessFunctionMutationVariables = Exact<{
input: ExecuteServerlessFunctionInput;
@@ -6334,14 +6356,14 @@ export type PublishOneServerlessFunctionMutationVariables = Exact<{
}>;
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type UpdateOneServerlessFunctionMutationVariables = Exact<{
input: UpdateServerlessFunctionInput;
}>;
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type FindManyAvailablePackagesQueryVariables = Exact<{
input: ServerlessFunctionIdInput;
@@ -6353,14 +6375,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl
export type GetManyServerlessFunctionsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null }> };
export type GetOneServerlessFunctionQueryVariables = Exact<{
input: ServerlessFunctionIdInput;
}>;
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: string, name: string, description?: string | null, runtime: string, timeoutSeconds: number, latestVersion?: string | null, publishedVersions: Array<string>, handlerPath: string, handlerName: string, toolInputSchema?: any | null, isTool: boolean, applicationId?: string | null, createdAt: string, updatedAt: string, cronTriggers?: Array<{ __typename?: 'CronTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, databaseEventTriggers?: Array<{ __typename?: 'DatabaseEventTrigger', id: string, settings: any, createdAt: string, updatedAt: string }> | null, routeTriggers?: Array<{ __typename?: 'RouteTrigger', id: string, path: string, isAuthRequired: boolean, httpMethod: HttpMethod, createdAt: string, updatedAt: string }> | null } };
export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{
input: GetServerlessFunctionSourceCodeInput;
@@ -7047,6 +7069,9 @@ export const ServerlessFunctionFieldsFragmentDoc = gql`
createdAt
updatedAt
}
toolInputSchema
isTool
applicationId
createdAt
updatedAt
}
@@ -8312,6 +8337,44 @@ export function useGetChatThreadsLazyQuery(baseOptions?: Apollo.LazyQueryHookOpt
export type GetChatThreadsQueryHookResult = ReturnType<typeof useGetChatThreadsQuery>;
export type GetChatThreadsLazyQueryHookResult = ReturnType<typeof useGetChatThreadsLazyQuery>;
export type GetChatThreadsQueryResult = Apollo.QueryResult<GetChatThreadsQuery, GetChatThreadsQueryVariables>;
export const GetToolIndexDocument = gql`
query GetToolIndex {
getToolIndex {
name
description
category
objectName
inputSchema
}
}
`;
/**
* __useGetToolIndexQuery__
*
* To run a query within a React component, call `useGetToolIndexQuery` and pass it any options that fit your needs.
* When your component renders, `useGetToolIndexQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetToolIndexQuery({
* variables: {
* },
* });
*/
export function useGetToolIndexQuery(baseOptions?: Apollo.QueryHookOptions<GetToolIndexQuery, GetToolIndexQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetToolIndexQuery, GetToolIndexQueryVariables>(GetToolIndexDocument, options);
}
export function useGetToolIndexLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetToolIndexQuery, GetToolIndexQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetToolIndexQuery, GetToolIndexQueryVariables>(GetToolIndexDocument, options);
}
export type GetToolIndexQueryHookResult = ReturnType<typeof useGetToolIndexQuery>;
export type GetToolIndexLazyQueryHookResult = ReturnType<typeof useGetToolIndexLazyQuery>;
export type GetToolIndexQueryResult = Apollo.QueryResult<GetToolIndexQuery, GetToolIndexQueryVariables>;
export const TrackAnalyticsDocument = gql`
mutation TrackAnalytics($type: AnalyticsType!, $event: String, $name: String, $properties: JSON) {
trackAnalytics(type: $type, event: $event, name: $name, properties: $properties) {
@@ -951,8 +951,10 @@ export type CreateServerlessFunctionInput = {
description?: InputMaybe<Scalars['String']>;
handlerName?: InputMaybe<Scalars['String']>;
handlerPath?: InputMaybe<Scalars['String']>;
isTool?: InputMaybe<Scalars['Boolean']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type CreateViewFieldInput = {
@@ -3298,6 +3300,7 @@ export type Query = {
getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal;
getTimelineThreadsFromOpportunityId: TimelineThreadsWithTotal;
getTimelineThreadsFromPersonId: TimelineThreadsWithTotal;
getToolIndex: Array<ToolIndexEntry>;
index: Index;
indexMetadatas: IndexConnection;
listPlans: Array<BillingPlanOutput>;
@@ -3895,6 +3898,7 @@ export type Sentry = {
export type ServerlessFunction = {
__typename?: 'ServerlessFunction';
applicationId?: Maybe<Scalars['UUID']>;
createdAt: Scalars['DateTime'];
cronTriggers?: Maybe<Array<CronTrigger>>;
databaseEventTriggers?: Maybe<Array<DatabaseEventTrigger>>;
@@ -3902,12 +3906,14 @@ export type ServerlessFunction = {
handlerName: Scalars['String'];
handlerPath: Scalars['String'];
id: Scalars['UUID'];
isTool: Scalars['Boolean'];
latestVersion?: Maybe<Scalars['String']>;
name: Scalars['String'];
publishedVersions: Array<Scalars['String']>;
routeTriggers?: Maybe<Array<RouteTrigger>>;
runtime: Scalars['String'];
timeoutSeconds: Scalars['Float'];
toolInputSchema?: Maybe<Scalars['JSON']>;
updatedAt: Scalars['DateTime'];
};
@@ -4205,6 +4211,15 @@ export type TimelineThreadsWithTotal = {
totalNumberOfThreads: Scalars['Int'];
};
export type ToolIndexEntry = {
__typename?: 'ToolIndexEntry';
category: Scalars['String'];
description: Scalars['String'];
inputSchema?: Maybe<Scalars['JSON']>;
name: Scalars['String'];
objectName?: Maybe<Scalars['String']>;
};
export type TransientTokenOutput = {
__typename?: 'TransientTokenOutput';
transientToken: AuthToken;
@@ -4444,8 +4459,10 @@ export type UpdateServerlessFunctionInputUpdates = {
description?: InputMaybe<Scalars['String']>;
handlerName?: InputMaybe<Scalars['String']>;
handlerPath?: InputMaybe<Scalars['String']>;
isTool?: InputMaybe<Scalars['Boolean']>;
name: Scalars['String'];
timeoutSeconds?: InputMaybe<Scalars['Float']>;
toolInputSchema?: InputMaybe<Scalars['JSON']>;
};
export type UpdateViewFieldInput = {
@@ -57,6 +57,40 @@ const InitialLoadingIndicator = () => {
);
};
const MessagePartRenderer = ({ part }: { part: ExtendedUIMessagePart }) => {
switch (part.type) {
case 'reasoning':
return (
<ReasoningSummaryDisplay
content={part.text}
isThinking={part.state === 'streaming'}
/>
);
case 'text':
return <LazyMarkdownRenderer text={part.text} />;
case 'data-routing-status':
return <RoutingStatusDisplay data={part.data} />;
case 'data-code-execution':
return (
<CodeExecutionDisplay
code={part.data.code}
stdout={part.data.stdout}
stderr={part.data.stderr}
exitCode={part.data.exitCode}
files={part.data.files}
isRunning={
part.data.state === 'running' || part.data.state === 'pending'
}
/>
);
default:
if (isToolUIPart(part)) {
return <ToolStepRenderer toolPart={part} />;
}
return null;
}
};
export const AIChatAssistantMessageRenderer = ({
messageParts,
isLastMessageStreaming,
@@ -75,44 +109,6 @@ export const AIChatAssistantMessageRenderer = ({
? messageParts.filter((part) => part.type !== 'data-code-execution')
: messageParts;
const renderMessagePart = (part: ExtendedUIMessagePart, index: number) => {
switch (part.type) {
case 'reasoning':
return (
<ReasoningSummaryDisplay
key={index}
content={part.text}
isThinking={part.state === 'streaming'}
/>
);
case 'text':
return <LazyMarkdownRenderer key={index} text={part.text} />;
case 'data-routing-status':
return <RoutingStatusDisplay data={part.data} key={index} />;
case 'data-code-execution':
return (
<CodeExecutionDisplay
key={index}
code={part.data.code}
stdout={part.data.stdout}
stderr={part.data.stderr}
exitCode={part.data.exitCode}
files={part.data.files}
isRunning={
part.data.state === 'running' || part.data.state === 'pending'
}
/>
);
default:
{
if (isToolUIPart(part)) {
return <ToolStepRenderer key={index} toolPart={part} />;
}
}
return null;
}
};
if (!filteredParts.length && !hasError) {
return <InitialLoadingIndicator />;
}
@@ -120,7 +116,9 @@ export const AIChatAssistantMessageRenderer = ({
return (
<div>
<StyledMessagePartsContainer>
{filteredParts.map(renderMessagePart)}
{filteredParts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
))}
</StyledMessagePartsContainer>
{isLastMessageStreaming && !hasError && <StyledStreamingIndicator />}
</div>
@@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
export const GET_TOOL_INDEX = gql`
query GetToolIndex {
getToolIndex {
name
description
category
objectName
inputSchema
}
}
`;
@@ -0,0 +1,28 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { GET_TOOL_INDEX } from '@/ai/graphql/queries/getToolIndex';
import { useQuery } from '@apollo/client';
type ToolIndexEntry = {
name: string;
description: string;
category: string;
objectName?: string;
};
type GetToolIndexQuery = {
getToolIndex: ToolIndexEntry[];
};
export const useGetToolIndex = () => {
const apolloMetadataClient = useApolloCoreClient();
const { data, loading, error } = useQuery<GetToolIndexQuery>(GET_TOOL_INDEX, {
client: apolloMetadataClient ?? undefined,
});
return {
toolIndex: data?.getToolIndex ?? [],
loading,
error,
};
};
@@ -454,6 +454,10 @@ export const SettingsRoutes = ({ isAdminPageEnabled }: SettingsRoutesProps) => (
path={SettingsPath.AISkillDetail}
element={<SettingsSkillForm mode="edit" />}
/>
<Route
path={SettingsPath.ServerlessFunctionDetail}
element={<SettingsServerlessFunctionDetail />}
/>
<Route path={SettingsPath.Billing} element={<SettingsBilling />} />
<Route path={SettingsPath.Domain} element={<SettingsDomain />} />
<Route
@@ -0,0 +1,20 @@
import { isDefined } from 'twenty-shared/utils';
export const getToolInputSchemaFromSourceCode = async (
sourceCode: string,
): Promise<object | null> => {
const { getFunctionInputSchema } = await import('./getFunctionInputSchema');
const inputSchema = getFunctionInputSchema(sourceCode);
// Serverless functions take a single params object
const firstParam = inputSchema[0];
if (firstParam?.type === 'object' && isDefined(firstParam.properties)) {
return {
type: 'object',
properties: firstParam.properties,
};
}
return null;
};
@@ -0,0 +1,36 @@
import { TitleInput } from '@/ui/input/components/TitleInput';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
const StyledHeaderTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.lg};
width: fit-content;
max-width: 420px;
& > input:disabled {
color: ${({ theme }) => theme.font.color.primary};
}
`;
type SettingsServerlessFunctionLabelContainerProps = {
value: string;
onChange: (value: string) => void;
};
export const SettingsServerlessFunctionLabelContainer = ({
value,
onChange,
}: SettingsServerlessFunctionLabelContainerProps) => {
return (
<StyledHeaderTitle>
<TitleInput
instanceId="serverless-function-name-input"
sizeVariant="md"
value={value}
onChange={onChange}
placeholder={t`Function name`}
/>
</StyledHeaderTitle>
);
};
@@ -21,11 +21,13 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
handleExecute,
onChange,
isTesting = false,
isManaged = false,
}: {
files: File[];
handleExecute: () => void;
onChange: (filePath: string, value: string) => void;
isTesting?: boolean;
isManaged?: boolean;
}) => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
@@ -64,12 +66,16 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
files={files}
currentFilePath={activeTabId}
onChange={(newCodeValue) => onChange(activeTabId, newCodeValue)}
options={{
readOnly: true,
readOnlyMessage: {
value: t`Managed serverless functions are not editable`,
},
}}
options={
isManaged
? {
readOnly: true,
readOnlyMessage: {
value: t`Managed serverless functions are not editable`,
},
}
: undefined
}
/>
)}
</Section>
@@ -1,6 +1,6 @@
import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/SettingsServerlessFunctionTabEnvironmentVariablesSection';
import { type ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
export const SettingsServerlessFunctionSettingsTab = ({
formValues,
@@ -14,7 +14,6 @@ export const SettingsServerlessFunctionSettingsTab = ({
<SettingsServerlessFunctionNewForm
formValues={formValues}
onChange={onChange}
readonly
/>
<SettingsServerlessFunctionTabEnvironmentVariablesSection />
</>
@@ -26,6 +26,16 @@ const StyledRouteTriggerTableHeaderRow = styled(StyledRouteTriggerTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledEmptyState = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
font-size: ${({ theme }) => theme.font.size.md};
height: 160px;
justify-content: center;
text-align: center;
`;
export const SettingsServerlessFunctionTriggersTab = ({
serverlessFunction,
}: {
@@ -46,6 +56,25 @@ export const SettingsServerlessFunctionTriggersTab = ({
return { object, action };
});
const hasNoTriggers =
databaseEvents.length === 0 &&
cronTriggers.length === 0 &&
routeTriggers.length === 0;
if (hasNoTriggers) {
return (
<Section>
<H2Title
title={t`Triggers`}
description={t`Configure when this function should be executed`}
/>
<StyledEmptyState>
{t`No triggers configured for this function.`}
</StyledEmptyState>
</Section>
);
}
return (
<>
{databaseEvents.length > 0 && (
@@ -31,6 +31,9 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
createdAt
updatedAt
}
toolInputSchema
isTool
applicationId
createdAt
updatedAt
}
@@ -1,15 +1,18 @@
import { useQuery } from '@apollo/client';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction';
import { useQuery } from '@apollo/client';
import {
type ServerlessFunctionIdInput,
type GetOneServerlessFunctionQuery,
type GetOneServerlessFunctionQueryVariables,
type ServerlessFunctionIdInput,
} from '~/generated-metadata/graphql';
export const useGetOneServerlessFunction = (
input: ServerlessFunctionIdInput,
) => {
export const useGetOneServerlessFunction = ({
id,
onCompleted,
}: ServerlessFunctionIdInput & {
onCompleted?: (data: GetOneServerlessFunctionQuery) => void;
}) => {
const apolloMetadataClient = useApolloCoreClient();
const { data, loading } = useQuery<
GetOneServerlessFunctionQuery,
@@ -17,8 +20,9 @@ export const useGetOneServerlessFunction = (
>(FIND_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? undefined,
variables: {
input,
input: { id },
},
onCompleted,
});
return {
serverlessFunction: data?.findOneServerlessFunction || null,
@@ -1,14 +1,17 @@
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
import { getFunctionInputFromSourceCode } from '@/serverless-functions/utils/getFunctionInputFromSourceCode';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/serverlessFunctionTestDataFamilyState';
import { type Dispatch, type SetStateAction, useState } from 'react';
import { useRecoilState } from 'recoil';
import { type FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
import { type ServerlessFunction } from '~/generated/graphql';
import { type Sources } from 'twenty-shared/types';
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
import { isDefined } from 'twenty-shared/utils';
import {
type FindOneServerlessFunctionSourceCodeQuery,
type GetOneServerlessFunctionQuery,
} from '~/generated-metadata/graphql';
import { type ServerlessFunction } from '~/generated/graphql';
export type ServerlessFunctionNewFormValues = {
name: string;
@@ -47,6 +50,17 @@ export const useServerlessFunctionUpdateFormState = ({
const { serverlessFunction, loading: serverlessFunctionLoading } =
useGetOneServerlessFunction({
id: serverlessFunctionId,
onCompleted: (data: GetOneServerlessFunctionQuery) => {
const fn = data?.findOneServerlessFunction;
if (isDefined(fn)) {
setFormValues((prevState) => ({
...prevState,
name: fn.name || '',
description: fn.description || '',
}));
}
},
});
const { loading: serverlessFunctionSourceCodeLoading } =
@@ -56,15 +70,9 @@ export const useServerlessFunctionUpdateFormState = ({
onCompleted: async (data: FindOneServerlessFunctionSourceCodeQuery) => {
const code = data?.getServerlessFunctionSourceCode;
const newState = {
code: code || undefined,
name: serverlessFunction?.name || '',
description: serverlessFunction?.description || '',
};
setFormValues((prevState) => ({
...prevState,
...newState,
code: code || prevState.code,
}));
if (serverlessFunctionTestData.shouldInitInput) {
@@ -116,7 +116,13 @@ export const useWorkflowAiAgentPermissionActions = ({
},
});
await refetchAgentAndRoles();
const { refetchedAgent } = await refetchAgentAndRoles();
// Update Recoil state with the refetched agent to ensure roleId is available
// Apollo's onCompleted is not called on refetch, so we need to update manually
if (isDefined(refetchedAgent)) {
setWorkflowAiAgentActionAgent(refetchedAgent);
}
return generatedRoleId;
};
@@ -294,7 +300,6 @@ export const useWorkflowAiAgentPermissionActions = ({
return;
}
const hadRoleBefore = isDefined(roleId);
const ensuredRoleId = await ensureRoleId();
if (!ensuredRoleId || permissionFlagKeys.includes(permissionFlagKey)) {
@@ -310,14 +315,7 @@ export const useWorkflowAiAgentPermissionActions = ({
},
});
const { refetchedAgent } = await refetchAgentAndRoles();
if (
!hadRoleBefore &&
isDefined(refetchedAgent) &&
isDefined(setWorkflowAiAgentActionAgent)
) {
setWorkflowAiAgentActionAgent(refetchedAgent);
}
await refetchAgentAndRoles();
setWorkflowAiAgentPermissionsIsAddingPermission(false);
setWorkflowAiAgentPermissionsSelectedObjectId(undefined);
};
@@ -1,14 +1,47 @@
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SubMenuTopBarContainer } from '@/ui/layout/page/components/SubMenuTopBarContainer';
import { TabList } from '@/ui/layout/tab-list/components/TabList';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { t } from '@lingui/core/macro';
import { IconSettings, IconSparkles, IconTool } from 'twenty-ui/display';
import { SettingsAIMCP } from './components/SettingsAIMCP';
import { SettingsAIRouterSettings } from './components/SettingsAIRouterSettings';
import { SettingsSkillsTable } from './components/SettingsSkillsTable';
import { SettingsToolsTable } from './components/SettingsToolsTable';
import { SETTINGS_AI_TABS } from './constants/SettingsAiTabs';
export const SettingsAI = () => {
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID,
);
const tabs = [
{
id: SETTINGS_AI_TABS.TABS_IDS.SKILLS,
title: t`Skills`,
Icon: IconSparkles,
},
{
id: SETTINGS_AI_TABS.TABS_IDS.TOOLS,
title: t`Tools`,
Icon: IconTool,
},
{
id: SETTINGS_AI_TABS.TABS_IDS.SETTINGS,
title: t`Settings`,
Icon: IconSettings,
},
];
const isSkillsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.SKILLS;
const isToolsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.TOOLS;
const isSettingsTab = activeTabId === SETTINGS_AI_TABS.TABS_IDS.SETTINGS;
return (
<SubMenuTopBarContainer
title={t`AI`}
@@ -21,9 +54,18 @@ export const SettingsAI = () => {
]}
>
<SettingsPageContainer>
<SettingsAIRouterSettings />
<SettingsSkillsTable />
<SettingsAIMCP />
<TabList
tabs={tabs}
componentInstanceId={SETTINGS_AI_TABS.COMPONENT_INSTANCE_ID}
/>
{isSkillsTab && <SettingsSkillsTable />}
{isToolsTab && <SettingsToolsTable />}
{isSettingsTab && (
<>
<SettingsAIRouterSettings />
<SettingsAIMCP />
</>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
);
@@ -367,52 +367,16 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
: agent?.label
: t`New Agent`;
const renderActiveTabContent = () => {
switch (activeTabId) {
case SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.ROLE:
return (
<SettingsAgentRoleTab
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isReadonlyMode || (isEditMode ? !agent?.isCustom : false)}
agentId={agentId}
agentLabel={formValues.label}
/>
);
const isRoleTab = activeTabId === SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.ROLE;
const isSettingsTab =
activeTabId === SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.SETTINGS;
const isEvalsTab = activeTabId === SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.EVALS;
const isLogsTab = activeTabId === SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.LOGS;
case SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.SETTINGS:
return (
<SettingsAgentSettingsTab
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isReadonlyMode || (isEditMode ? !agent?.isCustom : false)}
agent={agent}
/>
);
case SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.EVALS:
return (
<SettingsAgentEvalsTab
agentId={agentId}
evaluationInputs={formValues.evaluationInputs}
onEvaluationInputsChange={(inputs) =>
handleFieldChange('evaluationInputs', inputs)
}
disabled={
process.env.NODE_ENV === 'development'
? isReadonlyMode
: isReadonlyMode || (isEditMode ? !agent?.isCustom : false)
}
/>
);
case SETTINGS_AGENT_DETAIL_TABS.TABS_IDS.LOGS:
return <SettingsAgentLogsTab agentId={agentId} />;
default:
return <></>;
}
};
const isFormDisabled =
isReadonlyMode || (isEditMode ? !agent?.isCustom : false);
const isEvalsDisabled =
process.env.NODE_ENV === 'development' ? isReadonlyMode : isFormDisabled;
return (
<>
@@ -451,7 +415,34 @@ export const SettingsAgentForm = ({ mode }: { mode: 'create' | 'edit' }) => {
componentInstanceId={tabListComponentId}
/>
<StyledContentContainer>
{renderActiveTabContent()}
{isRoleTab && (
<SettingsAgentRoleTab
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isFormDisabled}
agentId={agentId}
agentLabel={formValues.label}
/>
)}
{isSettingsTab && (
<SettingsAgentSettingsTab
formValues={formValues}
onFieldChange={handleFieldChange}
disabled={isFormDisabled}
agent={agent}
/>
)}
{isEvalsTab && (
<SettingsAgentEvalsTab
agentId={agentId}
evaluationInputs={formValues.evaluationInputs}
onEvaluationInputsChange={(inputs) =>
handleFieldChange('evaluationInputs', inputs)
}
disabled={isEvalsDisabled}
/>
)}
{isLogsTab && <SettingsAgentLogsTab agentId={agentId} />}
</StyledContentContainer>
</>
)}
@@ -392,32 +392,26 @@ export const SettingsSkillForm = ({ mode }: { mode: 'create' | 'edit' }) => {
? t`Deactivate "Synchronize Label and API Name" to set a custom API name`
: t`Input must be in camel case and cannot start with a number`;
const renderTitle = () => {
if (isCreateMode) {
return t`New Skill`;
}
if (loading === true) {
return t`Skill`;
}
return (
<StyledHeaderTitle>
<TitleInput
instanceId="skill-label-input"
disabled={isReadonlyMode}
sizeVariant="md"
value={formValues.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder={t`Skill name`}
/>
</StyledHeaderTitle>
);
};
const title = isCreateMode ? (
t`New Skill`
) : loading ? (
t`Skill`
) : (
<StyledHeaderTitle>
<TitleInput
instanceId="skill-label-input"
disabled={isReadonlyMode}
sizeVariant="md"
value={formValues.label}
onChange={(value) => handleFieldChange('label', value)}
placeholder={t`Skill name`}
/>
</StyledHeaderTitle>
);
return (
<SubMenuTopBarContainer
title={renderTitle()}
title={title}
actionButton={
isCreateMode ? (
<SaveAndCancelButtons
@@ -16,15 +16,13 @@ import { useTheme } from '@emotion/react';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import {
H2Title,
IconArchive,
IconChevronRight,
IconFilterCog,
IconFilter,
IconPlus,
IconSearch,
} from 'twenty-ui/display';
import { Button, LightIconButton } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { Button } from 'twenty-ui/input';
import { MenuItemToggle, UndecoratedLink } from 'twenty-ui/navigation';
import {
@@ -40,29 +38,27 @@ import {
StyledSkillTableRow,
} from './SettingsSkillTableRow';
const StyledSearchContainer = styled.div`
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 100%;
align-items: center;
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
`;
const StyledTable = styled(Table)`
margin-top: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledTableHeaderRow = styled(StyledSkillTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledHeaderContainer = styled.div`
const StyledFooterContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.spacing(4)};
`;
export const SettingsSkillsTable = () => {
@@ -124,23 +120,8 @@ export const SettingsSkillsTable = () => {
};
return (
<Section>
<StyledHeaderContainer>
<H2Title
title={t`Skills`}
description={t`Skills available in the chat`}
/>
<UndecoratedLink to={getSettingsPath(SettingsPath.AINewSkill)}>
<Button
Icon={IconPlus}
title={t`New Skill`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledHeaderContainer>
<StyledSearchContainer>
<>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="skill-table-search"
LeftIcon={IconSearch}
@@ -150,11 +131,15 @@ export const SettingsSkillsTable = () => {
/>
<Dropdown
dropdownId="settings-skills-filter-dropdown"
dropdownPlacement="bottom-end"
dropdownOffset={{ x: 0, y: 8 }}
clickableComponent={
<LightIconButton
aria-label={t`Filter skills`}
Icon={IconFilterCog}
accent="tertiary"
<Button
Icon={IconFilter}
size="medium"
variant="secondary"
accent="default"
ariaLabel={t`Filter`}
/>
}
dropdownComponents={
@@ -171,9 +156,9 @@ export const SettingsSkillsTable = () => {
</DropdownContent>
}
/>
</StyledSearchContainer>
</StyledSearchAndFilterContainer>
<StyledTable>
<Table>
<StyledTableHeaderRow>
{SETTINGS_SKILL_TABLE_METADATA.fields.map(
(settingsSkillTableMetadataField) => (
@@ -221,7 +206,18 @@ export const SettingsSkillsTable = () => {
}
/>
))}
</StyledTable>
</Section>
</Table>
<StyledFooterContainer>
<UndecoratedLink to={getSettingsPath(SettingsPath.AINewSkill)}>
<Button
Icon={IconPlus}
title={t`New Skill`}
size="small"
variant="secondary"
/>
</UndecoratedLink>
</StyledFooterContainer>
</>
);
};
@@ -0,0 +1,166 @@
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { isDefined } from 'twenty-shared/utils';
import {
IconChevronDown,
IconChevronRight,
IconCode,
IconDatabase,
IconPlayerPlay,
IconSettings,
IconTable,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { JsonTree } from 'twenty-ui/json-visualizer';
import { AnimatedExpandableContainer } from 'twenty-ui/layout';
import { type JsonValue } from 'type-fest';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export type SystemTool = {
name: string;
description: string;
category: string;
objectName?: string;
inputSchema?: object;
};
export type SettingsSystemToolTableRowProps = {
tool: SystemTool;
};
export const StyledSystemToolTableRow = styled(TableRow)<{
isExpandable?: boolean;
}>`
grid-template-columns: 1fr 100px 36px;
opacity: 0.7;
cursor: ${({ isExpandable }) => (isExpandable ? 'pointer' : 'default')};
&:hover {
opacity: ${({ isExpandable }) => (isExpandable ? 0.85 : 0.7)};
}
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
overflow: hidden;
`;
const StyledIconContainer = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
const StyledActionTableCell = styled(TableCell)`
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledExpandableContent = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-top: 1px solid ${({ theme }) => theme.border.color.light};
padding: ${({ theme }) => theme.spacing(4)};
`;
const StyledSectionTitle = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const getCategoryIcon = (category: string) => {
switch (category) {
case 'database':
return IconDatabase;
case 'workflow':
return IconPlayerPlay;
case 'metadata':
return IconSettings;
case 'view':
return IconTable;
default:
return IconCode;
}
};
export const SettingsSystemToolTableRow = ({
tool,
}: SettingsSystemToolTableRowProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const { copyToClipboard } = useCopyToClipboard();
const Icon = getCategoryIcon(tool.category);
const hasInputSchema =
isDefined(tool.inputSchema) && Object.keys(tool.inputSchema).length > 0;
const handleRowClick = () => {
if (hasInputSchema) {
setIsExpanded(!isExpanded);
}
};
return (
<>
<StyledSystemToolTableRow
key={tool.name}
onClick={handleRowClick}
isExpandable={hasInputSchema}
>
<StyledNameTableCell>
<StyledIconContainer>
<Icon size={16} />
</StyledIconContainer>
<OverflowingTextWithTooltip text={tool.name} />
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag item={{ isCustom: false }} />
</TableCell>
<StyledActionTableCell>
{hasInputSchema &&
(isExpanded ? (
<IconChevronDown size={16} />
) : (
<IconChevronRight size={16} />
))}
</StyledActionTableCell>
</StyledSystemToolTableRow>
{hasInputSchema && (
<AnimatedExpandableContainer isExpanded={isExpanded} mode="fit-content">
<StyledExpandableContent>
{tool.description && (
<StyledDescription>{tool.description}</StyledDescription>
)}
<StyledSectionTitle>{t`Input Schema`}</StyledSectionTitle>
<JsonTree
value={tool.inputSchema as JsonValue}
shouldExpandNodeInitially={() => true}
emptyArrayLabel={t`Empty Array`}
emptyObjectLabel={t`No parameters`}
emptyStringLabel={t`[empty string]`}
arrowButtonCollapsedLabel={t`Expand`}
arrowButtonExpandedLabel={t`Collapse`}
onNodeValueClick={copyToClipboard}
/>
</StyledExpandableContent>
</AnimatedExpandableContainer>
)}
</>
);
};
@@ -0,0 +1,64 @@
import styled from '@emotion/styled';
import { type ReactNode } from 'react';
import { SettingsItemTypeTag } from '@/settings/components/SettingsItemTypeTag';
import { TableCell } from '@/ui/layout/table/components/TableCell';
import { TableRow } from '@/ui/layout/table/components/TableRow';
import { IconCode, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { type ServerlessFunction } from '~/generated-metadata/graphql';
type ToolWithApplicationId = ServerlessFunction & {
applicationId?: string | null;
};
export type SettingsToolTableRowProps = {
tool: ToolWithApplicationId;
action?: ReactNode;
link?: string;
};
export const StyledToolTableRow = styled(TableRow)`
grid-template-columns: 1fr 100px 36px;
`;
const StyledNameTableCell = styled(TableCell)`
color: ${({ theme }) => theme.font.color.primary};
gap: ${({ theme }) => theme.spacing(2)};
min-width: 0;
overflow: hidden;
`;
const StyledIconContainer = styled.div`
align-items: center;
display: flex;
flex-shrink: 0;
`;
const StyledActionTableCell = styled(TableCell)`
justify-content: flex-end;
padding-right: ${({ theme }) => theme.spacing(2)};
`;
export const SettingsToolTableRow = ({
tool,
action,
link,
}: SettingsToolTableRowProps) => {
return (
<StyledToolTableRow to={link}>
<StyledNameTableCell>
<StyledIconContainer>
<IconCode size={16} />
</StyledIconContainer>
<OverflowingTextWithTooltip text={tool.name} />
</StyledNameTableCell>
<TableCell>
<SettingsItemTypeTag
item={{ isCustom: true, applicationId: tool.applicationId }}
/>
</TableCell>
<StyledActionTableCell>{action}</StyledActionTableCell>
</StyledToolTableRow>
);
};
@@ -0,0 +1,258 @@
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useMemo, useState } from 'react';
import Skeleton from 'react-loading-skeleton';
import { useGetToolIndex } from '@/ai/hooks/useGetToolIndex';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { usePersistServerlessFunction } from '@/settings/serverless-functions/hooks/usePersistServerlessFunction';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SettingsTextInput } from '@/ui/input/components/SettingsTextInput';
import { Table } from '@/ui/layout/table/components/Table';
import { TableHeader } from '@/ui/layout/table/components/TableHeader';
import { useTheme } from '@emotion/react';
import { useNavigate } from 'react-router-dom';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
H2Title,
IconChevronRight,
IconPlus,
IconSearch,
} from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { Section } from 'twenty-ui/layout';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
import { SettingsSystemToolTableRow } from './SettingsSystemToolTableRow';
import {
SettingsToolTableRow,
StyledToolTableRow,
} from './SettingsToolTableRow';
const StyledSearchAndFilterContainer = styled.div`
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
padding-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledSearchInput = styled(SettingsTextInput)`
flex: 1;
width: 100%;
`;
const StyledTableHeaderRow = styled(StyledToolTableRow)`
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledFooterContainer = styled.div`
align-items: center;
display: flex;
justify-content: flex-end;
margin-top: ${({ theme }) => theme.spacing(4)};
`;
const DEFAULT_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {},
};
export const SettingsToolsTable = () => {
const { serverlessFunctions, loading } = useGetManyServerlessFunctions();
const { toolIndex, loading: toolIndexLoading } = useGetToolIndex();
const { createServerlessFunction } = usePersistServerlessFunction();
const { t } = useLingui();
const theme = useTheme();
const navigate = useNavigate();
const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
const [customSearchTerm, setCustomSearchTerm] = useState('');
const [builtInSearchTerm, setBuiltInSearchTerm] = useState('');
const [isCreating, setIsCreating] = useState(false);
// Filter to only show serverless functions that are marked as tools
const tools = useMemo(
() => serverlessFunctions.filter((fn) => fn.isTool === true),
[serverlessFunctions],
);
const filteredTools = useMemo(
() =>
tools.filter((tool) => {
const searchNormalized = normalizeSearchText(customSearchTerm);
const matchesSearch =
normalizeSearchText(tool.name).includes(searchNormalized) ||
normalizeSearchText(tool.description ?? '').includes(
searchNormalized,
);
return matchesSearch;
}),
[tools, customSearchTerm],
);
// System tools from the tool index (excluding serverless function tools which are shown separately)
const systemTools = useMemo(
() =>
toolIndex.filter(
(systemTool) => systemTool.category !== 'SERVERLESS_FUNCTION',
),
[toolIndex],
);
const filteredSystemTools = useMemo(
() =>
systemTools.filter((systemTool) => {
const searchNormalized = normalizeSearchText(builtInSearchTerm);
const matchesSearch =
normalizeSearchText(systemTool.name).includes(searchNormalized) ||
normalizeSearchText(systemTool.description).includes(
searchNormalized,
);
return matchesSearch;
}),
[systemTools, builtInSearchTerm],
);
const showSkeleton = (loading || toolIndexLoading) && tools.length === 0;
const handleCreateTool = async () => {
setIsCreating(true);
try {
const result = await createServerlessFunction({
input: {
name: 'new-tool',
isTool: true,
toolInputSchema: DEFAULT_TOOL_INPUT_SCHEMA,
},
});
if (result.status === 'successful' && isDefined(result.response?.data)) {
const newFunction = result.response.data.createOneServerlessFunction;
enqueueSuccessSnackBar({ message: t`Tool created` });
// Navigate to the serverless function detail page
// The applicationId might be null for workspace-level functions
const applicationId = (newFunction as { applicationId?: string })
.applicationId;
if (isDefined(applicationId)) {
navigate(
getSettingsPath(SettingsPath.ApplicationServerlessFunctionDetail, {
applicationId,
serverlessFunctionId: newFunction.id,
}),
);
} else {
navigate(
getSettingsPath(SettingsPath.ServerlessFunctionDetail, {
serverlessFunctionId: newFunction.id,
}),
);
}
}
} catch {
enqueueErrorSnackBar({ message: t`Failed to create tool` });
} finally {
setIsCreating(false);
}
};
const getToolLink = (tool: (typeof tools)[0]) => {
const applicationId = (tool as { applicationId?: string }).applicationId;
if (isDefined(applicationId)) {
return getSettingsPath(SettingsPath.ApplicationServerlessFunctionDetail, {
applicationId,
serverlessFunctionId: tool.id,
});
}
return getSettingsPath(SettingsPath.ServerlessFunctionDetail, {
serverlessFunctionId: tool.id,
});
};
return (
<>
<Section>
<H2Title
title={t`Custom`}
description={t`Custom tools created in your workspace`}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="custom-tool-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a custom tool...`}
value={customSearchTerm}
onChange={setCustomSearchTerm}
/>
</StyledSearchAndFilterContainer>
<Table>
<StyledTableHeaderRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Type`}</TableHeader>
<TableHeader />
</StyledTableHeaderRow>
{showSkeleton
? Array.from({ length: 3 }).map((_, index) => (
<Skeleton height={32} borderRadius={4} key={index} />
))
: filteredTools.map((tool) => (
<SettingsToolTableRow
key={tool.id}
tool={tool}
action={
<IconChevronRight
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
}
link={getToolLink(tool)}
/>
))}
</Table>
<StyledFooterContainer>
<Button
Icon={IconPlus}
title={t`New Tool`}
size="small"
variant="secondary"
onClick={handleCreateTool}
disabled={isCreating}
/>
</StyledFooterContainer>
</Section>
<Section>
<H2Title
title={t`Built-in`}
description={t`Standard tools available to AI agents`}
/>
<StyledSearchAndFilterContainer>
<StyledSearchInput
instanceId="builtin-tool-table-search"
LeftIcon={IconSearch}
placeholder={t`Search a built-in tool...`}
value={builtInSearchTerm}
onChange={setBuiltInSearchTerm}
/>
</StyledSearchAndFilterContainer>
<Table>
<StyledTableHeaderRow>
<TableHeader>{t`Name`}</TableHeader>
<TableHeader align="right">{t`Type`}</TableHeader>
<TableHeader />
</StyledTableHeaderRow>
{filteredSystemTools.map((systemTool) => (
<SettingsSystemToolTableRow
key={systemTool.name}
tool={systemTool}
/>
))}
</Table>
</Section>
</>
);
};
@@ -0,0 +1,8 @@
export const SETTINGS_AI_TABS = {
COMPONENT_INSTANCE_ID: 'settings-ai-tab-list',
TABS_IDS: {
SKILLS: 'skills',
TOOLS: 'tools',
SETTINGS: 'settings',
},
} as const;
@@ -1,7 +1,10 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { useTestServerlessFunction } from '@/serverless-functions/hooks/useTestServerlessFunction';
import { computeNewSources } from '@/serverless-functions/utils/computeNewSources';
import { flattenSources } from '@/serverless-functions/utils/flattenSources';
import { getToolInputSchemaFromSourceCode } from '@/serverless-functions/utils/getToolInputSchemaFromSourceCode';
import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer';
import { SettingsServerlessFunctionLabelContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionLabelContainer';
import { SettingsServerlessFunctionCodeEditorTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab';
import { SettingsServerlessFunctionSettingsTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab';
import { SettingsServerlessFunctionTestTab } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab';
@@ -14,8 +17,9 @@ import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTab
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { SettingsPath } from 'twenty-shared/types';
import { getSettingsPath } from 'twenty-shared/utils';
import { getSettingsPath, isDefined } from 'twenty-shared/utils';
import {
IconBolt,
IconCode,
@@ -31,6 +35,7 @@ export const SettingsServerlessFunctionDetail = () => {
const { serverlessFunctionId = '', applicationId = '' } = useParams();
const navigate = useNavigate();
const currentWorkspace = useRecoilValue(currentWorkspaceState);
const { data } = useFindOneApplicationQuery({
variables: { id: applicationId },
@@ -39,6 +44,15 @@ export const SettingsServerlessFunctionDetail = () => {
const applicationName = data?.findOneApplication?.name;
// A serverless function is "managed" if it belongs to an application
// other than the workspace's custom application
const workspaceCustomApplicationId =
currentWorkspace?.workspaceCustomApplication?.id;
const isManaged =
isDefined(applicationId) &&
applicationId !== '' &&
applicationId !== workspaceCustomApplicationId;
const instanceId = `${SERVERLESS_FUNCTION_DETAIL_ID}-${serverlessFunctionId}`;
const activeTabId = useRecoilComponentValue(
@@ -54,18 +68,22 @@ export const SettingsServerlessFunctionDetail = () => {
serverlessFunctionId,
});
const handleSave = useDebouncedCallback(async () => {
await updateServerlessFunction({
input: {
id: serverlessFunctionId,
update: {
name: formValues.name,
description: formValues.description,
code: formValues.code,
const handleSave = useDebouncedCallback(
async (toolInputSchema?: object | null) => {
await updateServerlessFunction({
input: {
id: serverlessFunctionId,
update: {
name: formValues.name,
description: formValues.description,
code: formValues.code,
...(toolInputSchema !== undefined && { toolInputSchema }),
},
},
},
});
}, 500);
});
},
500,
);
const onChange = (key: string) => {
return async (value: string) => {
@@ -88,7 +106,15 @@ export const SettingsServerlessFunctionDetail = () => {
}),
};
});
await handleSave();
// Parse and save schema if editing the handler file
let toolInputSchema: object | null | undefined;
if (filePath === serverlessFunction?.handlerPath) {
toolInputSchema = await getToolInputSchemaFromSourceCode(value);
}
await handleSave(toolInputSchema);
};
const handleTestFunction = async () => {
@@ -119,48 +145,14 @@ export const SettingsServerlessFunctionDetail = () => {
: 0,
);
const renderActiveTabContent = () => {
switch (activeTabId) {
case 'editor':
return (
<SettingsServerlessFunctionCodeEditorTab
files={files}
handleExecute={handleTestFunction}
onChange={onCodeChange}
isTesting={isTesting}
/>
);
case 'triggers':
return serverlessFunction ? (
<SettingsServerlessFunctionTriggersTab
serverlessFunction={serverlessFunction}
/>
) : null;
case 'test':
return (
<SettingsServerlessFunctionTestTab
serverlessFunctionId={serverlessFunctionId}
handleExecute={handleTestFunction}
isTesting={isTesting}
/>
);
case 'settings':
return (
<SettingsServerlessFunctionSettingsTab
formValues={formValues}
onChange={onChange}
/>
);
default:
return <></>;
}
};
const isEditorTab = activeTabId === 'editor';
const isTriggersTab = activeTabId === 'triggers';
const isTestTab = activeTabId === 'test';
const isSettingsTab = activeTabId === 'settings';
return (
!loading && (
<SubMenuTopBarContainer
title={formValues.name}
links={[
const breadcrumbLinks =
isDefined(applicationId) && applicationId !== ''
? [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
@@ -181,11 +173,59 @@ export const SettingsServerlessFunctionDetail = () => {
),
},
{ children: `${serverlessFunction?.name}` },
]}
]
: [
{
children: t`Workspace`,
href: getSettingsPath(SettingsPath.Workspace),
},
{
children: t`AI`,
href: getSettingsPath(SettingsPath.AI),
},
{ children: `${serverlessFunction?.name}` },
];
return (
!loading && (
<SubMenuTopBarContainer
title={
<SettingsServerlessFunctionLabelContainer
value={formValues.name}
onChange={onChange('name')}
/>
}
links={breadcrumbLinks}
>
<SettingsPageContainer>
<TabList tabs={tabs} componentInstanceId={instanceId} />
{renderActiveTabContent()}
{isEditorTab && (
<SettingsServerlessFunctionCodeEditorTab
files={files}
handleExecute={handleTestFunction}
onChange={onCodeChange}
isTesting={isTesting}
isManaged={isManaged}
/>
)}
{isTriggersTab && serverlessFunction && (
<SettingsServerlessFunctionTriggersTab
serverlessFunction={serverlessFunction}
/>
)}
{isTestTab && (
<SettingsServerlessFunctionTestTab
serverlessFunctionId={serverlessFunctionId}
handleExecute={handleTestFunction}
isTesting={isTesting}
/>
)}
{isSettingsTab && (
<SettingsServerlessFunctionSettingsTab
formValues={formValues}
onChange={onChange}
/>
)}
</SettingsPageContainer>
</SubMenuTopBarContainer>
)
@@ -0,0 +1,25 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class AddToolSchemaToServerlessFunction1767364430164
implements MigrationInterface
{
name = 'AddToolSchemaToServerlessFunction1767364430164';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."serverlessFunction" ADD "toolInputSchema" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."serverlessFunction" ADD "isTool" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."serverlessFunction" DROP COLUMN "isTool"`,
);
await queryRunner.query(
`ALTER TABLE "core"."serverlessFunction" DROP COLUMN "toolInputSchema"`,
);
}
}
@@ -11,14 +11,14 @@ import { type ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entit
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
describe('McpProtocolService', () => {
let service: McpProtocolService;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let toolProviderService: jest.Mocked<ToolProviderService>;
let toolRegistryService: jest.Mocked<ToolRegistryService>;
let userRoleService: jest.Mocked<UserRoleService>;
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
let apiKeyRoleService: jest.Mocked<ApiKeyRoleService>;
@@ -37,8 +37,8 @@ describe('McpProtocolService', () => {
isFeatureEnabled: jest.fn(),
};
const mockToolProviderService = {
getTools: jest.fn(),
const mockToolRegistryService = {
getToolsByCategories: jest.fn(),
};
const mockUserRoleService = {
@@ -62,8 +62,8 @@ describe('McpProtocolService', () => {
useValue: mockFeatureFlagService,
},
{
provide: ToolProviderService,
useValue: mockToolProviderService,
provide: ToolRegistryService,
useValue: mockToolRegistryService,
},
{
provide: UserRoleService,
@@ -82,7 +82,7 @@ describe('McpProtocolService', () => {
service = module.get<McpProtocolService>(McpProtocolService);
featureFlagService = module.get(FeatureFlagService);
toolProviderService = module.get(ToolProviderService);
toolRegistryService = module.get(ToolRegistryService);
userRoleService = module.get(UserRoleService);
mcpToolExecutorService = module.get(McpToolExecutorService);
apiKeyRoleService = module.get(ApiKeyRoleService);
@@ -227,7 +227,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -282,7 +282,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -316,7 +316,7 @@ describe('McpProtocolService', () => {
});
expect(result).toEqual(mockToolCallResponse);
expect(toolProviderService.getTools).toHaveBeenCalled();
expect(toolRegistryService.getToolsByCategories).toHaveBeenCalled();
});
it('should handle tools listing', async () => {
@@ -330,7 +330,7 @@ describe('McpProtocolService', () => {
},
};
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
toolRegistryService.getToolsByCategories.mockResolvedValue(mockToolsMap);
const mockToolsListingResponse = {
id: '123',
@@ -398,7 +398,7 @@ describe('McpProtocolService', () => {
it('should handle error when tool is not found', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
toolProviderService.getTools.mockResolvedValue({});
toolRegistryService.getToolsByCategories.mockResolvedValue({});
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
@@ -9,14 +9,14 @@ import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/service
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@Injectable()
export class MCPMetadataService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
private readonly metricsService: MetricsService,
) {}
@@ -50,11 +50,18 @@ export class MCPMetadataService {
}
async getTools(workspaceId: string): Promise<ToolSet> {
return this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.METADATA],
wrapWithErrorContext: false,
});
// Metadata tools don't require role-based permissions, using empty roleId
return this.toolRegistry.getToolsByCategories(
{
workspaceId,
roleId: '',
rolePermissionConfig: { unionOf: [] },
},
{
categories: [ToolCategory.METADATA],
wrapWithErrorContext: false,
},
);
}
async handleToolCall(
@@ -12,7 +12,7 @@ import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -21,7 +21,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
export class McpProtocolService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
private readonly apiKeyRoleService: ApiKeyRoleService,
@@ -143,16 +143,22 @@ export class McpProtocolService {
apiKey,
);
const toolSet = await this.toolProvider.getTools({
workspaceId: workspace.id,
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
rolePermissionConfig: { unionOf: [roleId] },
authContext,
wrapWithErrorContext: false,
// Exclude code_interpreter from MCP to prevent recursive execution attacks
// (code running in the sandbox could call code_interpreter via MCP)
excludeTools: [ToolType.CODE_INTERPRETER],
});
// Exclude code_interpreter from MCP to prevent recursive execution attacks
// (code running in the sandbox could call code_interpreter via MCP)
const toolSet = await this.toolRegistry.getToolsByCategories(
{
workspaceId: workspace.id,
roleId,
rolePermissionConfig: { unionOf: [roleId] },
authContext,
userWorkspaceId,
},
{
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
excludeTools: [ToolType.CODE_INTERPRETER],
wrapWithErrorContext: false,
},
);
if (method === 'tools/call' && params) {
return await this.mcpToolExecutorService.handleToolCall(
@@ -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 =
@@ -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,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,
});
};
@@ -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,
});
};
@@ -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,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>;
@@ -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'),
});
};
@@ -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>;
@@ -6,4 +6,5 @@ export enum ToolCategory {
NATIVE_MODEL = 'NATIVE_MODEL',
VIEW = 'VIEW',
DASHBOARD = 'DASHBOARD',
SERVERLESS_FUNCTION = 'SERVERLESS_FUNCTION',
}
@@ -2,21 +2,36 @@ import { type ToolSet } from 'ai';
import { type CodeExecutionData } from 'twenty-shared/ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CodeExecutionStreamEmitter = (data: CodeExecutionData) => void;
// Unified context for tool generation - used by all consumers
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
// Optional fields for different use cases
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
userId?: string;
userWorkspaceId?: string;
agent?: FlatAgentWithRoleId | null;
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
// Options for tool retrieval
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: ToolType[];
wrapWithErrorContext?: boolean;
};
export interface ToolProvider {
readonly category: ToolCategory;
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ZodObject, type ZodRawShape } from 'zod';
import {
type ToolProvider,
@@ -18,6 +19,10 @@ import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import {
stripLoadingMessage,
wrapSchemaForExecution,
} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
@@ -98,9 +103,11 @@ export class ActionToolProvider implements ToolProvider {
private createToolEntry(tool: Tool, context: ToolExecutionContext) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, context),
inputSchema: wrapSchemaForExecution(
tool.inputSchema as ZodObject<ZodRawShape>,
),
execute: async (parameters: ToolInput) =>
tool.execute(stripLoadingMessage(parameters), context),
};
}
}
@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
@Injectable()
export class NativeModelToolProvider implements ToolProvider {
readonly category = ToolCategory.NATIVE_MODEL;
constructor(
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return isDefined(context.agent);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
if (!context.agent) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(context.agent);
return this.agentModelConfigService.getNativeModelTools(
registeredModel,
context.agent,
);
}
}
@@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
import { jsonSchema, type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { type FlatServerlessFunction } from 'src/engine/metadata-modules/serverless-function/types/flat-serverless-function.type';
@Injectable()
export class ServerlessFunctionToolProvider implements ToolProvider {
readonly category = ToolCategory.SERVERLESS_FUNCTION;
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Serverless function tools are available if there are any functions marked as tools
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const { flatServerlessFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatServerlessFunctionMaps'],
},
);
// Filter serverless functions that are marked as tools
const serverlessFunctionsWithSchema = Object.values(
flatServerlessFunctionMaps.byId,
).filter(
(fn): fn is FlatServerlessFunction =>
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const tools: ToolSet = {};
for (const serverlessFunction of serverlessFunctionsWithSchema) {
const toolName = this.buildServerlessFunctionToolName(
serverlessFunction.name,
);
const wrappedSchema = wrapJsonSchemaForExecution(
serverlessFunction.toolInputSchema as Record<string, unknown>,
);
tools[toolName] = {
description:
serverlessFunction.description ||
`Execute the ${serverlessFunction.name} serverless function`,
inputSchema: jsonSchema(wrappedSchema),
execute: async (parameters: Record<string, unknown>) => {
const { loadingMessage: _, ...actualParams } = parameters;
const result =
await this.serverlessFunctionService.executeOneServerlessFunction({
id: serverlessFunction.id,
workspaceId: context.workspaceId,
payload: actualParams,
version: serverlessFunction.latestVersion ?? 'draft',
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
},
};
}
return tools;
}
private buildServerlessFunctionToolName(functionName: string): string {
// Convert function name to a valid tool name (lowercase, underscores)
return `serverless_${functionName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')}`;
}
}
@@ -0,0 +1,59 @@
import { UseGuards } from '@nestjs/common';
import { Field, ObjectType, Query, Resolver } from '@nestjs/graphql';
import graphqlTypeJson from 'graphql-type-json';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@ObjectType('ToolIndexEntry')
export class ToolIndexEntryDTO {
@Field()
name: string;
@Field()
description: string;
@Field()
category: string;
@Field({ nullable: true })
objectName?: string;
@Field(() => graphqlTypeJson, { nullable: true })
inputSchema?: object;
}
@Resolver()
@UseGuards(WorkspaceAuthGuard)
export class ToolIndexResolver {
constructor(
private readonly toolRegistryService: ToolRegistryService,
private readonly userRoleService: UserRoleService,
) {}
@Query(() => [ToolIndexEntryDTO])
@UseGuards(NoPermissionGuard)
async getToolIndex(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
): Promise<ToolIndexEntryDTO[]> {
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId: workspace.id,
});
if (!roleId) {
return [];
}
return this.toolRegistryService.buildToolIndex(workspace.id, roleId, {
userWorkspaceId,
});
}
}
@@ -1,352 +0,0 @@
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolSpecification } from 'src/engine/core-modules/tool-provider/types/tool-specification.type';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
// Type-only import to avoid circular dependency at file level
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
type ActionTool = {
tool: Tool;
flag?: PermissionFlagType;
};
@Injectable()
export class ToolProviderService {
private readonly logger = new Logger(ToolProviderService.name);
private readonly actionTools: Map<ToolType, ActionTool>;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
// Optional to avoid circular dependency with WorkflowExecutorModule (null when called from workflow context)
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly permissionsService: PermissionsService,
) {
this.actionTools = new Map([
[
ToolType.HTTP_REQUEST,
{
tool: this.httpTool,
flag: PermissionFlagType.HTTP_REQUEST_TOOL,
},
],
[
ToolType.SEND_EMAIL,
{
tool: this.sendEmailTool,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
},
],
[
ToolType.SEARCH_HELP_CENTER,
{
tool: this.searchHelpCenterTool,
},
],
[
ToolType.CODE_INTERPRETER,
{
tool: this.codeInterpreterTool,
flag: PermissionFlagType.CODE_INTERPRETER_TOOL,
},
],
]);
}
getToolByType(toolType: ToolType): Tool {
const actionTool = this.actionTools.get(toolType);
if (!actionTool) {
throw new Error(`Unknown tool type: ${toolType}`);
}
return actionTool.tool;
}
async getTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
for (const category of spec.categories) {
const categoryTools = await this.getToolsForCategory(category, spec);
Object.assign(tools, categoryTools);
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${spec.categories.join(', ')}]`,
);
if (spec.wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return tools;
}
private async getToolsForCategory(
category: ToolCategory,
spec: ToolSpecification,
): Promise<ToolSet> {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return this.getDatabaseTools(spec);
case ToolCategory.ACTION:
return this.getActionTools(spec);
case ToolCategory.WORKFLOW:
return this.getWorkflowTools(spec);
case ToolCategory.METADATA:
return this.getMetadataTools(spec);
case ToolCategory.NATIVE_MODEL:
return this.getNativeModelTools(spec);
default:
return {};
}
}
private async getDatabaseTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.rolePermissionConfig || !spec.authContext) {
return {};
}
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
return this.perObjectToolGenerator.generate(
{
workspaceId: spec.workspaceId,
authContext: spec.authContext,
rolePermissionConfig: spec.rolePermissionConfig,
actorContext: spec.actorContext,
},
[factory],
);
}
private async getActionTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
const executionContext = { workspaceId: spec.workspaceId };
const excludedTools = new Set(spec.excludeTools ?? []);
for (const [toolType, { tool, flag }] of this.actionTools) {
if (excludedTools.has(toolType)) {
continue;
}
if (!flag) {
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, executionContext),
};
} else if (spec.rolePermissionConfig && spec.workspaceId) {
const hasPermission = await this.permissionsService.hasToolPermission(
spec.rolePermissionConfig,
spec.workspaceId,
flag,
);
if (hasPermission) {
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, executionContext),
};
}
}
}
return tools;
}
private async getWorkflowTools(spec: ToolSpecification): Promise<ToolSet> {
if (!this.workflowToolService) {
return {};
}
if (!spec.rolePermissionConfig) {
return {};
}
const hasWorkflowPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (!hasWorkflowPermission) {
return {};
}
const workflowTools = this.workflowToolService.generateWorkflowTools(
spec.workspaceId,
spec.rolePermissionConfig,
);
const recordStepTools =
await this.workflowToolService.generateRecordStepConfiguratorTools(
spec.workspaceId,
spec.rolePermissionConfig,
);
return { ...workflowTools, ...recordStepTools };
}
private async getMetadataTools(spec: ToolSpecification): Promise<ToolSet> {
if (spec.rolePermissionConfig) {
const hasDataModelPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.DATA_MODEL,
);
if (!hasDataModelPermission) {
return {};
}
}
const objectMetadataTools = this.objectMetadataToolsFactory.generateTools(
spec.workspaceId,
);
const fieldMetadataTools = this.fieldMetadataToolsFactory.generateTools(
spec.workspaceId,
);
return { ...objectMetadataTools, ...fieldMetadataTools };
}
private async getNativeModelTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.agent) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(spec.agent);
return this.agentModelConfigService.getNativeModelTools(
registeredModel,
spec.agent,
);
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -1,12 +1,14 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ToolSet, zodSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ZodType } from 'zod';
import {
type CodeExecutionStreamEmitter,
type ToolProvider,
type ToolProviderContext,
type ToolRetrievalOptions,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
@@ -17,25 +19,28 @@ export type ToolIndexEntry = {
name: string;
description: string;
category:
| 'database'
| 'action'
| 'workflow'
| 'metadata'
| 'view'
| 'dashboard';
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'SERVERLESS_FUNCTION';
objectName?: string;
operation?: string;
inputSchema?: object;
};
export type ToolSearchOptions = {
limit?: number;
category?:
| 'database'
| 'action'
| 'workflow'
| 'metadata'
| 'view'
| 'dashboard';
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'SERVERLESS_FUNCTION';
};
export type ToolContext = {
@@ -183,6 +188,44 @@ export class ToolRegistryService {
);
}
// Main method for eager loading tools by categories (replaces ToolProviderService.getTools)
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const tools: ToolSet = {};
for (const provider of this.providers) {
if (categories && !categories.includes(provider.category)) {
continue;
}
if (await provider.isAvailable(context)) {
const providerTools = await provider.generateTools(context);
Object.assign(tools, providerTools);
}
}
// Apply excludeTools filter
if (excludeTools?.length) {
for (const toolType of excludeTools) {
delete tools[toolType.toLowerCase()];
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
);
// Apply error wrapping if requested
if (wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return tools;
}
private buildContext(
workspaceId: string,
roleId: string,
@@ -209,19 +252,176 @@ export class ToolRegistryService {
category: ToolCategory,
): ToolIndexEntry[] {
const categoryMap: Record<ToolCategory, ToolIndexEntry['category']> = {
DATABASE_CRUD: 'database',
ACTION: 'action',
WORKFLOW: 'workflow',
METADATA: 'metadata',
NATIVE_MODEL: 'action',
VIEW: 'view',
DASHBOARD: 'dashboard',
DATABASE_CRUD: 'DATABASE',
ACTION: 'ACTION',
WORKFLOW: 'WORKFLOW',
METADATA: 'METADATA',
NATIVE_MODEL: 'ACTION',
VIEW: 'VIEW',
DASHBOARD: 'DASHBOARD',
SERVERLESS_FUNCTION: 'SERVERLESS_FUNCTION',
};
return Object.entries(tools).map(([name, tool]) => ({
name,
description: tool.description ?? '',
category: categoryMap[category],
}));
return Object.entries(tools).map(([name, tool]) => {
const inputSchema = this.extractJsonSchema(tool.inputSchema);
return {
name,
description: tool.description ?? '',
category: categoryMap[category],
inputSchema,
};
});
}
private extractJsonSchema(inputSchema: unknown): object | undefined {
if (!inputSchema) {
return undefined;
}
let schema: object | undefined;
// Check if it's a Zod schema (has _def property)
if (
typeof inputSchema === 'object' &&
inputSchema !== null &&
'_def' in inputSchema
) {
try {
// Use AI SDK's zodSchema() to convert Zod to JSON Schema
const converted = zodSchema(inputSchema as ZodType);
schema = converted.jsonSchema as object;
} catch {
// If conversion fails, return undefined
return undefined;
}
} else if (
// Check if AI SDK wrapped it with jsonSchema property
typeof inputSchema === 'object' &&
inputSchema !== null &&
'jsonSchema' in inputSchema
) {
schema = (inputSchema as { jsonSchema: object }).jsonSchema;
} else if (typeof inputSchema === 'object') {
// Return as-is if it's already an object (plain JSON schema)
schema = inputSchema as object;
}
if (!schema) {
return undefined;
}
return this.stripInternalFieldsFromSchema(schema);
}
// Remove internal fields (loadingMessage) from schema for display
private stripInternalFieldsFromSchema(schema: object): object {
const schemaObj = schema as Record<string, unknown>;
// Remove $schema property
const { $schema: _, ...rest } = schemaObj;
// Remove loadingMessage from properties if present
// loadingMessage is an internal field auto-injected for AI status updates
if (
rest.type === 'object' &&
rest.properties &&
typeof rest.properties === 'object'
) {
const properties = rest.properties as Record<string, unknown>;
const { loadingMessage: __, ...cleanProperties } = properties;
// Filter required array to remove loadingMessage if present
const required = Array.isArray(rest.required)
? rest.required.filter((field) => field !== 'loadingMessage')
: undefined;
return {
...rest,
properties: cleanProperties,
...(required && required.length > 0 ? { required } : {}),
};
}
return rest;
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
_toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -7,6 +7,8 @@ import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/provid
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
import { DatabaseToolProvider } from 'src/engine/core-modules/tool-provider/providers/database-tool.provider';
import { MetadataToolProvider } from 'src/engine/core-modules/tool-provider/providers/metadata-tool.provider';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
import { ServerlessFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/serverless-function-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
@@ -16,10 +18,12 @@ import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ToolProviderService } from './services/tool-provider.service';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
// NOTE: This module does NOT import WorkflowToolsModule or DashboardToolsModule to avoid
@@ -40,12 +44,17 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ServerlessFunctionModule,
UserRoleModule,
],
providers: [
ToolIndexResolver,
ActionToolProvider,
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
ServerlessFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
{
@@ -55,6 +64,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider: DashboardToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
nativeModelProvider: NativeModelToolProvider,
serverlessFunctionProvider: ServerlessFunctionToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
@@ -62,6 +73,8 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider,
databaseProvider,
metadataProvider,
nativeModelProvider,
serverlessFunctionProvider,
viewProvider,
workflowProvider,
],
@@ -70,13 +83,14 @@ import { ToolRegistryService } from './services/tool-registry.service';
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
ServerlessFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
],
},
ToolProviderService,
ToolRegistryService,
],
exports: [ToolProviderService, ToolRegistryService],
exports: [ToolRegistryService],
})
export class ToolProviderModule {}
@@ -5,16 +5,14 @@ import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/fla
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
export const loadSkillInputSchema = z.object({
input: z.object({
skillNames: z
.array(z.string())
.describe(
'Names of the skills to load (e.g., ["workflow-building", "data-manipulation"])',
),
}),
skillNames: z
.array(z.string())
.describe(
'Names of the skills to load (e.g., ["workflow-building", "data-manipulation"])',
),
});
export type LoadSkillInput = z.infer<typeof loadSkillInputSchema>['input'];
export type LoadSkillInput = z.infer<typeof loadSkillInputSchema>;
export type LoadSkillResult = {
skills: Array<{
@@ -31,10 +29,8 @@ export const createLoadSkillTool = (loadSkills: LoadSkillFunction) => ({
description:
'Load specialized skills/expertise by name. Returns detailed instructions for workflows, data manipulation, dashboards, metadata, or research.',
inputSchema: loadSkillInputSchema,
execute: async (parameters: {
input: LoadSkillInput;
}): Promise<LoadSkillResult> => {
const { skillNames } = parameters.input;
execute: async (parameters: LoadSkillInput): Promise<LoadSkillResult> => {
const { skillNames } = parameters;
const skills = await loadSkills(skillNames);
@@ -8,16 +8,14 @@ import {
export const LOAD_TOOLS_TOOL_NAME = 'load_tools' as const;
export const loadToolsInputSchema = z.object({
input: z.object({
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
}),
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
});
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>['input'];
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>;
export type LoadToolsResult = {
loaded: string[];
@@ -37,10 +35,8 @@ export const createLoadToolsTool = (
) => ({
description: `Load tools by name to make them available for use. Call this when you need to use a tool from the catalog that isn't already loaded. You can load multiple tools at once.`,
inputSchema: loadToolsInputSchema,
execute: async (parameters: {
input: LoadToolsInput;
}): Promise<LoadToolsResult> => {
const { toolNames } = parameters.input;
execute: async (parameters: LoadToolsInput): Promise<LoadToolsResult> => {
const { toolNames } = parameters;
const loaded: string[] = [];
const notFound: string[] = [];
@@ -1,20 +0,0 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolSpecification = {
workspaceId: string;
categories: ToolCategory[];
rolePermissionConfig?: RolePermissionConfig;
authContext?: WorkspaceAuthContext;
actorContext?: ActorMetadata;
agent?: FlatAgentWithRoleId | null;
wrapWithErrorContext?: boolean;
// Tools to exclude from the generated toolset (security: prevent recursive code execution)
excludeTools?: ToolType[];
};
@@ -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,
});
@@ -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,
@@ -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) {}
@@ -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,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,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,
});
@@ -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,
@@ -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;
};
@@ -12,9 +12,10 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { type WorkspaceAuthContext } from 'src/engine/api/common/interfaces/workspace-auth-context.interface';
import { 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 { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
@@ -40,7 +41,7 @@ export class AgentAsyncExecutorService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolProvider: ToolProviderService,
private readonly toolRegistry: ToolRegistryService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
) {}
@@ -119,21 +120,28 @@ export class AgentAsyncExecutorService {
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
// Workflow tools are excluded to prevent circular dependencies
tools = await this.toolProvider.getTools({
workspaceId: agent.workspaceId,
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
rolePermissionConfig: effectiveRoleConfig,
authContext,
actorContext,
agent: agent as unknown as Parameters<
typeof this.toolProvider.getTools
>[0]['agent'],
wrapWithErrorContext: false,
});
const roleId = this.extractRoleIds(effectiveRoleConfig)[0] ?? '';
tools = await this.toolRegistry.getToolsByCategories(
{
workspaceId: agent.workspaceId,
roleId,
rolePermissionConfig: effectiveRoleConfig ?? { unionOf: [] },
authContext,
actorContext,
agent: agent as unknown as ToolProviderContext['agent'],
userId: authContext?.user?.id,
userWorkspaceId: authContext?.userWorkspaceId,
},
{
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
wrapWithErrorContext: false,
},
);
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -387,12 +387,13 @@ ${preloadedTools.length > 0 ? preloadedTools.map((t) => `- \`${t}\` ✓`).join('
### Tool Catalog by Category`);
const categoryOrder = [
'database',
'action',
'workflow',
'dashboard',
'metadata',
'view',
'DATABASE',
'ACTION',
'WORKFLOW',
'DASHBOARD',
'METADATA',
'VIEW',
'SERVERLESS_FUNCTION',
];
for (const category of categoryOrder) {
@@ -426,18 +427,20 @@ ${tools
private getCategoryLabel(category: string): string {
switch (category) {
case 'database':
case 'DATABASE':
return 'Database Tools (CRUD operations)';
case 'action':
case 'ACTION':
return 'Action Tools (HTTP, Email, etc.)';
case 'workflow':
case 'WORKFLOW':
return 'Workflow Tools (create/manage workflows)';
case 'metadata':
case 'METADATA':
return 'Metadata Tools (schema management)';
case 'view':
case 'VIEW':
return 'View Tools (query views)';
case 'dashboard':
case 'DASHBOARD':
return 'Dashboard Tools (create/manage dashboards)';
case 'SERVERLESS_FUNCTION':
return 'Serverless Functions (custom tools)';
default:
return category;
}
@@ -10,134 +10,98 @@ import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-mod
import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2';
const GetFieldMetadataInputSchema = 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 field metadata. If provided, returns a single field.',
),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter fields by object metadata ID.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of fields to return.'),
}),
.describe(
'Unique identifier for the field metadata. If provided, returns a single field.',
),
objectMetadataId: z
.string()
.uuid()
.optional()
.describe('Filter fields by object metadata ID.'),
limit: z
.number()
.int()
.min(1)
.max(100)
.default(100)
.describe('Maximum number of fields to return.'),
});
const CreateFieldMetadataInputSchema = z.object({
loadingMessage: z
objectMetadataId: z
.string()
.uuid()
.describe('ID of the object to add the field to'),
type: z
.nativeEnum(FieldMetadataType)
.describe(
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
),
name: z.string().describe('Internal name of the field (camelCase)'),
label: z.string().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
objectMetadataId: z
.string()
.uuid()
.describe('ID of the object to add the field to'),
type: z
.nativeEnum(FieldMetadataType)
.describe(
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
),
name: z.string().describe('Internal name of the field (camelCase)'),
label: z.string().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isNullable: z
.boolean()
.optional()
.describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z
.unknown()
.optional()
.describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
isRemoteCreation: z
.boolean()
.optional()
.describe('Whether this is a remote field creation'),
relationCreationPayload: z
.unknown()
.optional()
.describe('Payload for creating relation fields'),
}),
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
isRemoteCreation: z
.boolean()
.optional()
.describe('Whether this is a remote field creation'),
relationCreationPayload: z
.unknown()
.optional()
.describe('Payload for creating relation fields'),
});
const UpdateFieldMetadataInputSchema = z.object({
loadingMessage: z
.string()
id: z.string().uuid().describe('ID of the field to update'),
name: z.string().optional().describe('Internal name of the field'),
label: z.string().optional().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isActive: z.boolean().optional().describe('Whether the field is active'),
isNullable: z.boolean().optional().describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('A clear description of the action being performed.'),
input: z.object({
id: z.string().uuid().describe('ID of the field to update'),
name: z.string().optional().describe('Internal name of the field'),
label: z.string().optional().describe('Display label of the field'),
description: z.string().optional().describe('Description of the field'),
icon: z.string().optional().describe('Icon identifier for the field'),
isActive: z.boolean().optional().describe('Whether the field is active'),
isNullable: z
.boolean()
.optional()
.describe('Whether the field can be null'),
isUnique: z
.boolean()
.optional()
.describe('Whether the field value must be unique'),
defaultValue: z
.unknown()
.optional()
.describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
}),
.describe('Whether the field value must be unique'),
defaultValue: z.unknown().optional().describe('Default value for the field'),
options: z
.unknown()
.optional()
.describe('Options for SELECT/MULTI_SELECT fields'),
settings: z
.unknown()
.optional()
.describe('Additional settings for the field'),
isLabelSyncedWithName: z
.boolean()
.optional()
.describe('Whether label should sync with name changes'),
});
const DeleteFieldMetadataInputSchema = 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 field to delete'),
}),
id: z.string().uuid().describe('ID of the field to delete'),
});
@Injectable()
@@ -151,21 +115,21 @@ export class FieldMetadataToolsFactory {
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
inputSchema: GetFieldMetadataInputSchema,
execute: async (parameters: {
input: { id?: string; objectMetadataId?: string; limit?: number };
id?: string;
objectMetadataId?: string;
limit?: number;
}) => {
return this.fieldMetadataService.query({
filter: {
workspaceId: { eq: workspaceId },
...(parameters.input.id
? { id: { eq: parameters.input.id } }
: {}),
...(parameters.input.objectMetadataId
...(parameters.id ? { id: { eq: parameters.id } } : {}),
...(parameters.objectMetadataId
? {
objectMetadataId: { eq: parameters.input.objectMetadataId },
objectMetadataId: { eq: parameters.objectMetadataId },
}
: {}),
},
paging: { limit: parameters.input.limit ?? 100 },
paging: { limit: parameters.limit ?? 100 },
});
},
},
@@ -174,27 +138,25 @@ export class FieldMetadataToolsFactory {
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
inputSchema: CreateFieldMetadataInputSchema,
execute: async (parameters: {
input: {
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
};
objectMetadataId: string;
type: FieldMetadataType;
name: string;
label: string;
description?: string;
icon?: string;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
isRemoteCreation?: boolean;
relationCreationPayload?: unknown;
}) => {
try {
const flatFieldMetadata =
await this.fieldMetadataService.createOneField({
createFieldInput: parameters.input as Parameters<
createFieldInput: parameters as Parameters<
typeof this.fieldMetadataService.createOneField
>[0]['createFieldInput'],
workspaceId,
@@ -214,23 +176,21 @@ export class FieldMetadataToolsFactory {
'Update an existing field metadata. Provide the field ID and the properties to update.',
inputSchema: UpdateFieldMetadataInputSchema,
execute: async (parameters: {
input: {
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
};
id: string;
name?: string;
label?: string;
description?: string;
icon?: string;
isActive?: boolean;
isNullable?: boolean;
isUnique?: boolean;
defaultValue?: unknown;
options?: unknown;
settings?: unknown;
isLabelSyncedWithName?: boolean;
}) => {
try {
const { id, ...update } = parameters.input;
const { id, ...update } = parameters;
const flatFieldMetadata =
await this.fieldMetadataService.updateOneField({
@@ -252,11 +212,11 @@ export class FieldMetadataToolsFactory {
delete_field_metadata: {
description: 'Delete a field metadata by its ID.',
inputSchema: DeleteFieldMetadataInputSchema,
execute: async (parameters: { input: { id: string } }) => {
execute: async (parameters: { id: string }) => {
try {
const flatFieldMetadata =
await this.fieldMetadataService.deleteOneField({
deleteOneFieldInput: { id: parameters.input.id },
deleteOneFieldInput: { id: parameters.id },
workspaceId,
});
@@ -80,7 +80,7 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
),
'deletedAt',
],
propertiesToStringify: [],
propertiesToStringify: ['toolInputSchema'],
},
cronTrigger: {
propertiesToCompare: [...FLAT_CRON_TRIGGER_EDITABLE_PROPERTIES],
@@ -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,
});
@@ -0,0 +1,9 @@
// Default tool input schema matching the base-typescript-project template
// Template params: { a: string; b: number; }
export const DEFAULT_TOOL_INPUT_SCHEMA = {
type: 'object',
properties: {
a: { type: 'string' },
b: { type: 'number' },
},
};
@@ -8,4 +8,6 @@ export const FLAT_SERVERLESS_FUNCTION_EDITABLE_PROPERTIES = [
'code',
'handlerPath',
'handlerName',
'toolInputSchema',
'isTool',
] as const satisfies (keyof FlatServerlessFunction)[];
@@ -1,6 +1,7 @@
import { Field, HideField, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -54,4 +55,14 @@ export class CreateServerlessFunctionInput {
@Field({ nullable: true })
@IsOptional()
handlerPath?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
}
@@ -7,12 +7,16 @@ import {
} from '@ptc-org/nestjs-query-graphql';
import {
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsNumber,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import graphqlTypeJson from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CronTriggerDTO } from 'src/engine/metadata-modules/cron-trigger/dtos/cron-trigger.dto';
@@ -69,6 +73,15 @@ export class ServerlessFunctionDTO {
@Field(() => [String], { nullable: false })
publishedVersions: string[];
@IsObject()
@IsOptional()
@Field(() => graphqlTypeJson, { nullable: true })
toolInputSchema?: object;
@IsBoolean()
@Field()
isTool: boolean;
@Field(() => [CronTriggerDTO], { nullable: true })
cronTriggers?: CronTriggerDTO[];
@@ -78,6 +91,11 @@ export class ServerlessFunctionDTO {
@Field(() => [RouteTriggerDTO], { nullable: true })
routeTriggers?: RouteTriggerDTO[];
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@HideField()
workspaceId: string;
@@ -2,6 +2,7 @@ import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsNotEmpty,
IsNumber,
IsObject,
@@ -50,6 +51,16 @@ class UpdateServerlessFunctionInputUpdates {
@Field({ nullable: true })
@IsOptional()
handlerPath?: string;
@Field(() => graphqlTypeJson, { nullable: true })
@IsObject()
@IsOptional()
toolInputSchema?: object;
@IsBoolean()
@Field({ nullable: true })
@IsOptional()
isTool?: boolean;
}
@InputType()
@@ -66,6 +66,12 @@ export class ServerlessFunctionEntity
@Column({ nullable: true, type: 'text' })
checksum: string | null;
@Column({ nullable: true, type: 'jsonb' })
toolInputSchema: object | null;
@Column({ nullable: false, default: false })
isTool: boolean;
@Column({ nullable: false, type: 'uuid' })
serverlessFunctionLayerId: string;
@@ -4,26 +4,29 @@ import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import deepEqual from 'deep-equal';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { Sources } from 'twenty-shared/types';
import {
DEFAULT_API_KEY_NAME,
DEFAULT_API_URL_NAME,
} from 'twenty-shared/application';
import { Sources } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IsNull, Not, Repository } from 'typeorm';
import { FileStorageExceptionCode } from 'src/engine/core-modules/file-storage/interfaces/file-storage-exception';
import { type ServerlessExecuteResult } from 'src/engine/core-modules/serverless/drivers/interfaces/serverless-driver.interface';
import { AuditService } from 'src/engine/core-modules/audit/services/audit.service';
import { SERVERLESS_FUNCTION_EXECUTED_EVENT } from 'src/engine/core-modules/audit/utils/events/workspace-event/serverless-function/serverless-function-executed';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { getBaseTypescriptProjectFiles } from 'src/engine/core-modules/serverless/drivers/utils/get-base-typescript-project-files';
import { ServerlessService } from 'src/engine/core-modules/serverless/serverless.service';
import { getServerlessFolder } from 'src/engine/core-modules/serverless/utils/serverless-get-folder.utils';
import { ThrottlerService } from 'src/engine/core-modules/throttler/throttler.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ServerlessFunctionLayerService } from 'src/engine/metadata-modules/serverless-function-layer/serverless-function-layer.service';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import { type UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
@@ -31,15 +34,13 @@ import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import {
WorkflowVersionStepException,
WorkflowVersionStepExceptionCode,
} from 'src/modules/workflow/common/exceptions/workflow-version-step.exception';
import { ApplicationTokenService } from 'src/engine/core-modules/auth/token/services/application-token.service';
import { buildEnvVar } from 'src/engine/core-modules/serverless/drivers/utils/build-env-var';
import { cleanServerUrl } from 'src/utils/clean-server-url';
import { SubscriptionService } from 'src/engine/subscriptions/subscription.service';
import { SubscriptionChannel } from 'src/engine/subscriptions/enums/subscription-channel.enum';
const MIN_TOKEN_EXPIRATION_IN_SECONDS = 5;
@@ -334,6 +335,8 @@ export class ServerlessFunctionService {
name: serverlessFunctionInput.update.name,
description: serverlessFunctionInput.update.description,
timeoutSeconds: serverlessFunctionInput.update.timeoutSeconds,
toolInputSchema: serverlessFunctionInput.update.toolInputSchema,
isTool: serverlessFunctionInput.update.isTool,
},
);
@@ -405,8 +408,14 @@ export class ServerlessFunctionService {
serverlessFunctionLayerId: serverlessFunctionToCreateLayerId,
};
// If no toolInputSchema is provided, use the default schema
// (because the default template will be used for the code)
const toolInputSchema = isDefined(serverlessFunctionInput.toolInputSchema)
? serverlessFunctionInput.toolInputSchema
: DEFAULT_TOOL_INPUT_SCHEMA;
const serverlessFunctionToCreate = this.serverlessFunctionRepository.create(
{ ...createServerlessFunctionInput, workspaceId },
{ ...createServerlessFunctionInput, workspaceId, toolInputSchema },
);
const createdServerlessFunction =
@@ -1,5 +1,7 @@
import { v4 } from 'uuid';
import { isDefined } from 'twenty-shared/utils';
import { DEFAULT_TOOL_INPUT_SCHEMA } from 'src/engine/metadata-modules/serverless-function/constants/default-tool-input-schema.constant';
import { type CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import {
DEFAULT_HANDLER_NAME,
@@ -55,5 +57,15 @@ export const fromCreateServerlessFunctionInputToFlatServerlessFunction = ({
JSON.stringify(rawCreateServerlessFunctionInput.code),
)
: null,
// If no schema provided and no code provided, use default schema
// (because the default template will be used)
toolInputSchema: isDefined(
rawCreateServerlessFunctionInput?.toolInputSchema,
)
? rawCreateServerlessFunctionInput.toolInputSchema
: !isDefined(rawCreateServerlessFunctionInput?.code)
? DEFAULT_TOOL_INPUT_SCHEMA
: null,
isTool: rawCreateServerlessFunctionInput?.isTool ?? false,
};
};
@@ -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,
});
@@ -3,7 +3,6 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service';
import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-agent-validator.service';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
import { FlatCronTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-cron-trigger-validator.service';
import { FlatDatabaseEventTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-database-event-trigger-validator.service';
import { FlatFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-field-metadata-validator.service';
@@ -18,6 +17,7 @@ import { FlatRouteTriggerValidatorService } from 'src/engine/workspace-manager/w
import { FlatRowLevelPermissionPredicateGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-row-level-permission-predicate-group-validator.service';
import { FlatRowLevelPermissionPredicateValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-row-level-permission-predicate-validator.service';
import { FlatServerlessFunctionValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-serverless-function-validator.service';
import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-skill-validator.service';
import { FlatViewFieldValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-field-validator.service';
import { FlatViewFilterGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-group-validator.service';
import { FlatViewFilterValidatorService } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-builder-v2/validators/services/flat-view-filter-validator.service';
@@ -17,6 +17,7 @@ export type { ObjectManifest } from './objectManifestType';
export type { PackageJson } from './packageJsonType';
export type { RoleManifest } from './roleManifestType';
export type {
InputJsonSchema,
ServerlessFunctionManifest,
DatabaseEventTrigger,
CronTrigger,
@@ -1,5 +1,23 @@
import { type HTTPMethod } from '@/types';
import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType';
import { type HTTPMethod } from '@/types';
// Standard JSON Schema type for tool input/output definitions
export type InputJsonSchema = {
type?:
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'integer'
| 'null';
description?: string;
enum?: unknown[];
items?: InputJsonSchema;
properties?: Record<string, InputJsonSchema>;
required?: string[];
additionalProperties?: boolean | InputJsonSchema;
};
export type ServerlessFunctionManifest = SyncableEntityOptions & {
name?: string;
@@ -8,6 +26,8 @@ export type ServerlessFunctionManifest = SyncableEntityOptions & {
triggers: ServerlessFunctionTriggerManifest[];
handlerPath: string;
handlerName: string;
toolInputSchema?: InputJsonSchema;
isTool?: boolean;
};
export type DatabaseEventTrigger = {
-1
View File
@@ -7,5 +7,4 @@
* |___/
*/
export default {};