feat(ai): add browsing context and fix tool loading (#16476)

## Summary

- Add `BrowsingContext` type to automatically pass what the user is
currently viewing (recordPage or listView) to the AI chat
- Simplify context architecture: remove toggleable context UI, make it
automatic and invisible to the user
- Fix tool loading: add `unionOf` handling in
`getDatabaseToolsForObject` and fix regex ordering so `find_one_*` tools
are properly registered
- Use plural names for find tools (`find_people` vs `find_one_person`)
for better semantics
- Clean up unused components and states

## Changes

### Frontend
- New `BrowsingContext` type and `useGetBrowsingContext` hook to gather
context from Recoil state
- Simplified `useAgentChat` to use the new browsing context
- Removed toggleable context UI components
(`AgentChatContextRecordPreview`, `SendMessageWithRecordsContextButton`,
etc.)
- Removed `isAgentChatCurrentContextActiveState`

### Backend
- New `BrowsingContextType` for recordPage and listView contexts
- Updated `ChatExecutionService` to build context from browsing context
- Fixed `tool-registry.service.ts`:
  - Added `unionOf` handling in permission config
- Fixed regex ordering (`find_one` before `find`) so tools load
correctly
- Use plural names for search tools (`find_people` instead of
`find_person`)

## Test plan

- [x] Typecheck passes
- [x] Lint passes
- [ ] Test AI chat on record page - should show context in system prompt
- [ ] Test AI chat on list view - should show view name and filters
- [ ] Test `find_one_*` tools now load correctly
- [ ] Test `find_*` tools use plural naming
This commit is contained in:
Félix Malfait
2025-12-11 10:19:27 +01:00
committed by GitHub
parent 5daef28328
commit bc57b8ee4e
35 changed files with 696 additions and 834 deletions
@@ -37,8 +37,8 @@ export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
const tools: ToolSet = {};
if (canRead) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
tools[`find_${objectMetadata.namePlural}`] = {
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolInputSchema(
objectMetadata,
restrictedFields,
@@ -0,0 +1 @@
export const TOOL_PROVIDERS = Symbol('TOOL_PROVIDERS');
@@ -0,0 +1,20 @@
import { type ToolSet } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type ToolProviderContext = {
workspaceId: string;
roleId: string;
rolePermissionConfig: RolePermissionConfig;
actorContext?: ActorMetadata;
};
export interface ToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,80 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { 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 { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class ActionToolProvider implements ToolProvider {
readonly category = ToolCategory.ACTION;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Action tools are always available (individual tool permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.HTTP_REQUEST_TOOL,
);
if (hasHttpPermission) {
tools['http_request'] = this.createToolEntry(
this.httpTool,
context.workspaceId,
);
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.SEND_EMAIL_TOOL,
);
if (hasEmailPermission) {
tools['send_email'] = this.createToolEntry(
this.sendEmailTool,
context.workspaceId,
);
}
tools['search_help_center'] = this.createToolEntry(
this.searchHelpCenterTool,
context.workspaceId,
);
return tools;
}
private createToolEntry(tool: Tool, workspaceId: string) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input, workspaceId),
};
}
}
@@ -0,0 +1,148 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
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 { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class DatabaseToolProvider implements ToolProvider {
readonly category = ToolCategory.DATABASE_CRUD;
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Database tools are always available (per-object permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'rolesPermissions',
]);
const objectPermissions = this.getObjectPermissions(
rolesPermissions,
context.rolePermissionConfig,
);
if (!objectPermissions) {
return tools;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId: context.workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
continue;
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
continue;
}
const objectMetadata = {
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
};
const objectTools = factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId: context.workspaceId,
rolePermissionConfig: context.rolePermissionConfig,
actorContext: context.actorContext,
},
);
Object.assign(tools, objectTools);
}
return tools;
}
private getObjectPermissions(
rolesPermissions: ObjectsPermissionsByRoleId,
rolePermissionConfig: ToolProviderContext['rolePermissionConfig'],
): ObjectsPermissions | null {
if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
return allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
}
if ('unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
return rolesPermissions[rolePermissionConfig.unionOf[0]];
}
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
return null;
}
}
@@ -0,0 +1,40 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
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 { 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';
@Injectable()
export class MetadataToolProvider implements ToolProvider {
readonly category = ToolCategory.METADATA;
constructor(
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.DATA_MODEL,
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
return {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
}
}
@@ -0,0 +1,49 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
type ToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
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 { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@Injectable()
export class WorkflowToolProvider implements ToolProvider {
readonly category = ToolCategory.WORKFLOW;
constructor(
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
) {}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.workflowToolService) {
return false;
}
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.WORKFLOWS,
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
if (!this.workflowToolService) {
return {};
}
return this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
}
}
@@ -14,7 +14,7 @@ import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-c
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 { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-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';
@@ -41,7 +41,7 @@ export class ToolProviderService {
// Action tools (individual tools)
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchArticlesTool: SearchArticlesTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
// Database CRUD tools
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
private readonly createRecordService: CreateRecordService,
@@ -80,9 +80,9 @@ export class ToolProviderService {
},
],
[
ToolType.SEARCH_ARTICLES,
ToolType.SEARCH_HELP_CENTER,
{
tool: this.searchArticlesTool,
tool: this.searchHelpCenterTool,
// No permission flag - available to all agents
},
],
@@ -1,28 +1,16 @@
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ActorMetadata } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
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 { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import {
type ToolProvider,
type ToolProviderContext,
} 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';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
export type ToolIndexEntry = {
name: string;
@@ -43,109 +31,35 @@ export type ToolContext = {
actorContext?: ActorMetadata;
};
// Workflow tool definitions for the index (static metadata)
const WORKFLOW_TOOLS_METADATA: Array<{ name: string; description: string }> = [
{
name: 'create_complete_workflow',
description:
'Create a complete workflow with trigger, steps, and connections in a single operation',
},
{
name: 'create_workflow_version_step',
description: 'Create a new step in a workflow version',
},
{
name: 'update_workflow_version_step',
description: 'Update an existing step in a workflow version',
},
{
name: 'delete_workflow_version_step',
description: 'Delete a step from a workflow version',
},
{
name: 'create_workflow_version_edge',
description: 'Create a connection (edge) between two workflow steps',
},
{
name: 'delete_workflow_version_edge',
description: 'Delete a connection (edge) between workflow steps',
},
{
name: 'create_draft_from_workflow_version',
description: 'Create a new draft workflow version from an existing one',
},
{
name: 'update_workflow_version_positions',
description: 'Update the positions of multiple workflow steps',
},
{
name: 'activate_workflow_version',
description:
'Activate a workflow version to make it available for execution',
},
{
name: 'deactivate_workflow_version',
description: 'Deactivate a workflow version',
},
{
name: 'compute_step_output_schema',
description: 'Compute the output schema for a workflow step',
},
];
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly permissionsService: PermissionsService,
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchArticlesTool: SearchArticlesTool,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
) {}
async buildToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const context = this.buildContext(workspaceId, roleId);
const entries: ToolIndexEntry[] = [];
const actionTools = await this.getActionToolIndex(workspaceId, roleId);
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const tools = await provider.generateTools(context);
index.push(...actionTools);
const databaseTools = await this.getDatabaseToolIndex(workspaceId, roleId);
index.push(...databaseTools);
if (this.workflowToolService) {
const workflowTools = this.getWorkflowToolIndex();
index.push(...workflowTools);
entries.push(...this.toolSetToIndex(tools, provider.category));
}
}
this.logger.log(
`Built tool index with ${index.length} tools for workspace ${workspaceId}`,
`Built tool index with ${entries.length} tools for workspace ${workspaceId}`,
);
return index;
}
private getWorkflowToolIndex(): ToolIndexEntry[] {
return WORKFLOW_TOOLS_METADATA.map((tool) => ({
name: tool.name,
description: tool.description,
category: 'workflow' as const,
}));
return entries;
}
async searchTools(
@@ -170,17 +84,14 @@ export class ToolRegistryService {
const descLower = tool.description.toLowerCase();
const objectLower = tool.objectName?.toLowerCase() ?? '';
// Exact name match - highest priority
if (nameLower.includes(queryLower)) {
score += 100;
}
// Object name match - high priority
if (objectLower && queryLower.includes(objectLower)) {
score += 80;
}
// Term matches in name
for (const term of queryTerms) {
if (nameLower.includes(term)) {
score += 30;
@@ -193,7 +104,6 @@ export class ToolRegistryService {
}
}
// Operation keyword matches
const operations = ['find', 'create', 'update', 'delete', 'search'];
for (const op of operations) {
@@ -220,346 +130,55 @@ export class ToolRegistryService {
names: string[],
context: ToolContext,
): Promise<ToolSet> {
const tools: ToolSet = {};
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [context.roleId],
};
const fullContext = this.buildContext(context.workspaceId, context.roleId);
const allTools: ToolSet = {};
for (const name of names) {
const tool = await this.getToolByName(
name,
context.workspaceId,
rolePermissionConfig,
context.actorContext,
);
for (const provider of this.providers) {
if (await provider.isAvailable(fullContext)) {
const tools = await provider.generateTools(fullContext);
if (tool) {
tools[name] = tool;
Object.assign(allTools, tools);
}
}
return tools;
return Object.fromEntries(
names
.filter((name) => name in allTools)
.map((name) => [name, allTools[name]]),
);
}
private async getToolByName(
name: string,
private buildContext(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
actorContext?: ActorMetadata,
): Promise<ToolSet[string] | null> {
const actionTool = this.getActionToolByName(name, workspaceId);
roleId: string,
): ToolProviderContext {
const rolePermissionConfig: RolePermissionConfig = {
unionOf: [roleId],
};
if (actionTool) {
return actionTool;
}
return {
workspaceId,
roleId,
rolePermissionConfig,
};
}
const workflowTool = await this.getWorkflowToolByName(
private toolSetToIndex(
tools: ToolSet,
category: ToolCategory,
): ToolIndexEntry[] {
const categoryMap: Record<ToolCategory, ToolIndexEntry['category']> = {
DATABASE_CRUD: 'database',
ACTION: 'action',
WORKFLOW: 'workflow',
METADATA: 'metadata',
NATIVE_MODEL: 'action',
};
return Object.entries(tools).map(([name, tool]) => ({
name,
workspaceId,
rolePermissionConfig,
);
if (workflowTool) {
return workflowTool;
}
const match = name.match(
/^(find|find_one|create|update|soft_delete)_(.+)$/,
);
if (match) {
const [, _operation, objectName] = match;
const dbTools = await this.getDatabaseToolsForObject(
workspaceId,
rolePermissionConfig,
objectName,
actorContext,
);
return dbTools[name] ?? null;
}
return null;
}
private async getWorkflowToolByName(
name: string,
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): Promise<ToolSet[string] | null> {
if (!this.workflowToolService) {
return null;
}
const isWorkflowTool = WORKFLOW_TOOLS_METADATA.some(
(tool) => tool.name === name,
);
if (!isWorkflowTool) {
return null;
}
// Generate workflow tools and return the requested one
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
rolePermissionConfig,
);
return workflowTools[name] ?? null;
}
private async getActionToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [roleId],
};
// HTTP Request tool
const hasHttpPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
PermissionFlagType.HTTP_REQUEST_TOOL,
);
if (hasHttpPermission) {
index.push({
name: 'http_request',
description: this.httpTool.description,
category: 'action',
});
}
// Send Email tool
const hasEmailPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
PermissionFlagType.SEND_EMAIL_TOOL,
);
if (hasEmailPermission) {
index.push({
name: 'send_email',
description: this.sendEmailTool.description,
category: 'action',
});
}
index.push({
name: 'search_articles',
description: this.searchArticlesTool.description,
category: 'action',
});
return index;
}
private getActionToolByName(
name: string,
workspaceId: string,
): ToolSet[string] | null {
switch (name) {
case 'http_request':
return {
description: this.httpTool.description,
inputSchema: this.httpTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.httpTool.inputSchema>['input'];
}) => this.httpTool.execute(parameters.input, workspaceId),
};
case 'send_email':
return {
description: this.sendEmailTool.description,
inputSchema: this.sendEmailTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.sendEmailTool.inputSchema>['input'];
}) => this.sendEmailTool.execute(parameters.input, workspaceId),
};
case 'search_articles':
return {
description: this.searchArticlesTool.description,
inputSchema: this.searchArticlesTool.inputSchema,
execute: async (parameters: {
input: z.infer<typeof this.searchArticlesTool.inputSchema>['input'];
}) => this.searchArticlesTool.execute(parameters.input),
};
default:
return null;
}
}
private async getDatabaseToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
const objectPermissions = rolesPermissions[roleId];
if (!objectPermissions) {
return index;
}
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
continue;
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
continue;
}
const objectName = flatObject.nameSingular;
const objectLabel = flatObject.labelSingular;
if (permission.canReadObjectRecords) {
index.push({
name: `find_${objectName}`,
description: `Search and find ${objectLabel} records`,
category: 'database',
objectName,
operation: 'find',
});
index.push({
name: `find_one_${objectName}`,
description: `Get a single ${objectLabel} record by ID`,
category: 'database',
objectName,
operation: 'find_one',
});
}
if (permission.canUpdateObjectRecords) {
index.push({
name: `create_${objectName}`,
description: `Create new ${objectLabel} records`,
category: 'database',
objectName,
operation: 'create',
});
index.push({
name: `update_${objectName}`,
description: `Update existing ${objectLabel} records`,
category: 'database',
objectName,
operation: 'update',
});
}
if (permission.canSoftDeleteObjectRecords) {
index.push({
name: `soft_delete_${objectName}`,
description: `Soft delete ${objectLabel} records`,
category: 'database',
objectName,
operation: 'soft_delete',
});
}
}
return index;
}
private async getDatabaseToolsForObject(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
objectName: string,
actorContext?: ActorMetadata,
): Promise<ToolSet> {
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
let objectPermissions;
if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
objectPermissions =
allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
} else {
return {};
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const flatObject = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.find((obj) => obj.nameSingular === objectName);
if (!flatObject) {
return {};
}
const permission = objectPermissions[flatObject.id];
if (!permission) {
return {};
}
const objectMetadata = {
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
};
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
return factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId,
rolePermissionConfig,
actorContext,
},
);
description: tool.description ?? '',
category: categoryMap[category],
}));
}
}
@@ -2,6 +2,11 @@ import { forwardRef, Module } from '@nestjs/common';
import { RecordCrudModule } from 'src/engine/core-modules/record-crud/record-crud.module';
import { ToolGeneratorModule } from 'src/engine/core-modules/tool-generator/tool-generator.module';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-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 { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
@@ -28,7 +33,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
ToolGeneratorModule,
RecordCrudModule,
AiModelsModule,
// forwardRef needed: AiAgentExecutionModule imports ToolProviderModule
forwardRef(() => AiAgentExecutionModule),
ObjectMetadataModule,
FieldMetadataModule,
@@ -36,7 +40,34 @@ import { ToolRegistryService } from './services/tool-registry.service';
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [ToolProviderService, ToolRegistryService],
providers: [
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
WorkflowToolProvider,
{
provide: TOOL_PROVIDERS,
useFactory: (
actionProvider: ActionToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
workflowProvider: WorkflowToolProvider,
) => [
actionProvider,
databaseProvider,
metadataProvider,
workflowProvider,
],
inject: [
ActionToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
WorkflowToolProvider,
],
},
ToolProviderService,
ToolRegistryService,
],
exports: [ToolProviderService, ToolRegistryService],
})
export class ToolProviderModule {}
@@ -1,5 +1,5 @@
export enum ToolType {
HTTP_REQUEST = 'HTTP_REQUEST',
SEND_EMAIL = 'SEND_EMAIL',
SEARCH_ARTICLES = 'SEARCH_ARTICLES',
SEARCH_HELP_CENTER = 'SEARCH_HELP_CENTER',
}
@@ -4,10 +4,10 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type SendEmailInput } from 'src/engine/core-modules/tool/tools/send-email-tool/types/send-email-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { type TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class ToolRegistryService {
@@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-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 { MessagingImportManagerModule } from 'src/modules/messaging/message-import-manager/messaging-import-manager.module';
@@ -14,7 +14,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
TypeOrmModule.forFeature([FileEntity]),
FileModule,
],
providers: [HttpTool, SendEmailTool, SearchArticlesTool],
exports: [HttpTool, SendEmailTool, SearchArticlesTool],
providers: [HttpTool, SendEmailTool, SearchHelpCenterTool],
exports: [HttpTool, SendEmailTool, SearchHelpCenterTool],
})
export class ToolModule {}
@@ -1,18 +1,20 @@
import { z } from 'zod';
export const SearchArticlesInputZodSchema = z.object({
export const SearchHelpCenterInputZodSchema = z.object({
query: z
.string()
.describe('The search query to find relevant help articles about Twenty'),
});
export const SearchArticlesToolParametersZodSchema = z.object({
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 articles for..."). Explain what you are searching for in natural language.',
'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: SearchArticlesInputZodSchema,
input: SearchHelpCenterInputZodSchema,
});
export type SearchArticlesInput = z.infer<typeof SearchArticlesInputZodSchema>;
export type SearchHelpCenterInput = z.infer<
typeof SearchHelpCenterInputZodSchema
>;
@@ -2,17 +2,17 @@ import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { SearchArticlesToolParametersZodSchema } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool.schema';
import { SearchHelpCenterToolParametersZodSchema } 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 { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@Injectable()
export class SearchArticlesTool implements Tool {
export class SearchHelpCenterTool implements Tool {
description =
'Search Twenty documentation and help articles to find information about features, setup, usage, and troubleshooting.';
inputSchema = SearchArticlesToolParametersZodSchema;
'Search Twenty documentation and help center to find information about features, setup, usage, and troubleshooting.';
inputSchema = SearchHelpCenterToolParametersZodSchema;
constructor(private readonly twentyConfigService: TwentyConfigService) {}
@@ -46,14 +46,14 @@ export class SearchArticlesTool implements Tool {
if (results.length === 0) {
return {
success: true,
message: `No help articles found for "${query}"`,
message: `No help center articles found for "${query}"`,
result: [],
};
}
return {
success: true,
message: `Found ${results.length} relevant help article${results.length === 1 ? '' : 's'} for "${query}"`,
message: `Found ${results.length} relevant help center article${results.length === 1 ? '' : 's'} for "${query}"`,
result: results,
};
} catch (error) {
@@ -61,11 +61,11 @@ export class SearchArticlesTool implements Tool {
? error.response?.data?.message || error.message
: error instanceof Error
? error.message
: 'Documentation search failed';
: 'Help center search failed';
return {
success: false,
message: `Failed to search help articles for "${query}"`,
message: `Failed to search help center for "${query}"`,
error: errorDetail,
};
}
@@ -5,7 +5,7 @@ import { render, toPlainText } from '@react-email/render';
import DOMPurify from 'dompurify';
import { reactMarkupFromJSON } from 'twenty-emails';
import { isDefined, isValidUuid } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { In, type Repository } from 'typeorm';
import { z } from 'zod';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';