feat: simplify AI chat architecture and add record links (#16463)

## Summary

This PR significantly simplifies the AI chat architecture by removing
complex routing/planning mechanisms and introduces clickable record
links in AI responses.

## Changes

### AI Chat Architecture Simplification
- **Removed** the entire `ai-chat-router` module (~850 lines) including:
  - Strategy decider service
  - Plan generator service
  - Complex routing logic
- **Removed** agent execution planning services (~700 lines):
  - `agent-execution.service.ts`
  - `agent-plan-executor.service.ts`
  - `agent-tool-generator.service.ts`
- **Added** centralized `ToolRegistryService` for tool management:
  - Builds searchable tool index (database, action, workflow tools)
  - Provides tool lookup by name
  - Supports agent search for loading expertise
- **Added** `ChatExecutionService` as simple replacement:
  - Includes full tool catalog in system prompt
- Pre-loads common tools (find/create/update for company, person,
opportunity, task, note)
  - Uses `load_tools` mechanism for dynamic tool activation
  - Enables native web search by default

### Record References in AI Responses
- Added `recordReferences` field to tool outputs for create, find, and
update operations
- Implemented `[[record:objectName:recordId:displayName]]` syntax for AI
to reference records
- Created `RecordLink` component that renders clickable chips with
object icons
- Integrated record link parsing into the markdown renderer
- Users can now click directly on created/found records in AI responses

### Workflow Agent Fixes
- Fixed cache invalidation issue when creating agents in workflows
- Added default prompt for workflow-created agents to prevent validation
errors
- Relaxed agent validation to only check properties being updated (not
all required properties)

### Code Quality Improvements
- Extracted `getRecordDisplayName` utility that mirrors frontend's
`getLabelIdentifierFieldValue` logic
- Uses object metadata to determine the correct label identifier field
- Handles `FULL_NAME` composite type for person/workspaceMember objects
- Shared across create, find, and update record services

## Net Impact
- **~1,200 lines deleted** (complex routing/planning code)
- **~500 lines added** (simpler tool registry + record links)
- Significantly reduced code complexity
- Better tool discovery through full catalog in system prompt
- Improved UX with clickable record references

## Testing
- Typecheck passes
- Lint passes
- Manual testing of AI chat with record creation and linking
This commit is contained in:
Félix Malfait
2025-12-10 15:14:12 +01:00
committed by GitHub
parent 59b54f254e
commit 5df8fd90c3
49 changed files with 1708 additions and 2700 deletions
@@ -9,6 +9,7 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type CreateRecordParams } from 'src/engine/core-modules/record-crud/types/create-record-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
@@ -137,6 +138,17 @@ export class CreateRecordService {
success: true,
message: `Record created successfully in ${objectName}`,
result: createdRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: createdRecord.id,
displayName: getRecordDisplayName(
{ ...transformedObjectRecord, ...createdRecord },
flatObjectMetadata,
flatFieldMetadataMaps,
),
},
],
};
} catch (error) {
if (error instanceof RecordCrudException) {
@@ -19,6 +19,7 @@ import {
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
@@ -114,6 +115,16 @@ export class FindRecordsService {
this.logger.log(`Found ${records.length} records in ${objectName}`);
const recordReferences = records.map((record) => ({
objectNameSingular: objectName,
recordId: record.id as string,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
}));
return {
success: true,
message: `Found ${records.length} ${objectName} records`,
@@ -121,6 +132,7 @@ export class FindRecordsService {
records,
count: totalCount,
},
recordReferences,
};
} catch (error) {
this.logger.error(`Failed to find records: ${error}`);
@@ -9,6 +9,7 @@ import {
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { type UpdateRecordParams } from 'src/engine/core-modules/record-crud/types/update-record-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { getSelectedColumnsFromRestrictedFields } from 'src/engine/core-modules/record-crud/utils/get-selected-columns-from-restricted-fields.util';
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@@ -167,6 +168,17 @@ export class UpdateRecordService {
success: true,
message: `Record updated successfully in ${objectName}`,
result: updatedObjectRecord,
recordReferences: [
{
objectNameSingular: objectName,
recordId: objectRecordId,
displayName: getRecordDisplayName(
updatedObjectRecord,
flatObjectMetadata,
flatFieldMetadataMaps,
),
},
],
};
} catch (error) {
if (error instanceof RecordCrudException) {
@@ -0,0 +1,46 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
// Mirrors frontend's getLabelIdentifierFieldValue logic
export const getRecordDisplayName = (
record: Record<string, unknown>,
flatObjectMetadata: FlatObjectMetadata,
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>,
): string => {
const { labelIdentifierFieldMetadataId } = flatObjectMetadata;
if (!isDefined(labelIdentifierFieldMetadataId)) {
return String(record.id ?? 'Unknown');
}
const labelIdentifierField = findFlatEntityByIdInFlatEntityMaps({
flatEntityMaps: flatFieldMetadataMaps,
flatEntityId: labelIdentifierFieldMetadataId,
});
if (!isDefined(labelIdentifierField)) {
return String(record.id ?? 'Unknown');
}
const fieldValue = record[labelIdentifierField.name];
// Handle FULL_NAME composite type (person, workspaceMember)
if (labelIdentifierField.type === FieldMetadataType.FULL_NAME) {
const nameValue = fieldValue as
| { firstName?: string; lastName?: string }
| undefined;
const firstName = nameValue?.firstName ?? '';
const lastName = nameValue?.lastName ?? '';
return `${firstName} ${lastName}`.trim() || String(record.id) || 'Unknown';
}
return isDefined(fieldValue)
? String(fieldValue)
: String(record.id ?? 'Unknown');
};
@@ -10,10 +10,6 @@ import {
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import {
type ToolHints,
type ToolOperation,
} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
@@ -32,12 +28,10 @@ export class PerObjectToolGeneratorService {
async generate(
context: ToolGeneratorContext,
factories: ToolFactory[],
toolHints?: ToolHints,
): Promise<ToolSet> {
const objects = await this.getFilteredObjectsWithPermissions(
const objects = await this.getObjectsWithPermissions(
context.workspaceId,
context.rolePermissionConfig,
toolHints,
);
const tools: ToolSet = {};
@@ -55,11 +49,10 @@ export class PerObjectToolGeneratorService {
return tools;
}
// Get workspace objects with their permissions, filtered by toolHints
async getFilteredObjectsWithPermissions(
// Get workspace objects with their permissions
async getObjectsWithPermissions(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
toolHints?: ToolHints,
): Promise<ObjectWithPermission[]> {
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
@@ -110,41 +103,13 @@ export class PerObjectToolGeneratorService {
}));
// Filter out workflow-related objects
let filteredObjectMetadata = allObjectMetadata.filter(
const filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
);
// Apply toolHints filtering if provided
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
const relevantSet = new Set(toolHints.relevantObjects);
const originalCount = filteredObjectMetadata.length;
filteredObjectMetadata = filteredObjectMetadata.filter(
(obj) =>
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
);
this.logger.log(
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
);
if (filteredObjectMetadata.length === 0) {
this.logger.warn(
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
);
}
}
// Map to ObjectWithPermission
const result: ObjectWithPermission[] = [];
const operationsSet = toolHints?.operations
? new Set(toolHints.operations)
: null;
const shouldIncludeOperation = (operation: ToolOperation) =>
!operationsSet || operationsSet.has(operation);
for (const objectMetadata of filteredObjectMetadata) {
const permission = objectPermissions[objectMetadata.id];
@@ -155,24 +120,13 @@ export class PerObjectToolGeneratorService {
result.push({
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate:
shouldIncludeOperation('create') && permission.canUpdateObjectRecords,
canRead:
shouldIncludeOperation('find') && permission.canReadObjectRecords,
canUpdate:
shouldIncludeOperation('update') && permission.canUpdateObjectRecords,
canDelete:
shouldIncludeOperation('delete') &&
permission.canSoftDeleteObjectRecords,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
});
}
if (operationsSet) {
this.logger.log(
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
);
}
return result;
}
}
@@ -159,7 +159,6 @@ export class ToolProviderService {
actorContext: spec.actorContext,
},
[factory],
spec.toolHints,
);
}
@@ -227,7 +226,6 @@ export class ToolProviderService {
await this.workflowToolService.generateRecordStepConfiguratorTools(
spec.workspaceId,
spec.rolePermissionConfig,
spec.toolHints,
);
return { ...workflowTools, ...recordStepTools };
@@ -0,0 +1,565 @@
import { Inject, Injectable, Logger, Optional } 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 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;
description: string;
category: 'database' | 'action' | 'workflow' | 'metadata';
objectName?: string;
operation?: string;
};
export type ToolSearchOptions = {
limit?: number;
category?: 'database' | 'action' | 'workflow' | 'metadata';
};
export type ToolContext = {
workspaceId: string;
roleId: string;
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,
) {}
async buildToolIndex(
workspaceId: string,
roleId: string,
): Promise<ToolIndexEntry[]> {
const index: ToolIndexEntry[] = [];
const actionTools = await this.getActionToolIndex(workspaceId, roleId);
index.push(...actionTools);
const databaseTools = await this.getDatabaseToolIndex(workspaceId, roleId);
index.push(...databaseTools);
if (this.workflowToolService) {
const workflowTools = this.getWorkflowToolIndex();
index.push(...workflowTools);
}
this.logger.log(
`Built tool index with ${index.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,
}));
}
async searchTools(
query: string,
workspaceId: string,
roleId: string,
options: ToolSearchOptions = {},
): Promise<ToolIndexEntry[]> {
const { limit = 5, category } = options;
const index = await this.buildToolIndex(workspaceId, roleId);
const queryLower = query.toLowerCase();
const queryTerms = queryLower
.split(/\s+/)
.filter((term) => term.length > 2);
const scored = index
.filter((tool) => !category || tool.category === category)
.map((tool) => {
let score = 0;
const nameLower = tool.name.toLowerCase();
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;
}
if (objectLower.includes(term)) {
score += 25;
}
if (descLower.includes(term)) {
score += 10;
}
}
// Operation keyword matches
const operations = ['find', 'create', 'update', 'delete', 'search'];
for (const op of operations) {
if (queryLower.includes(op) && nameLower.includes(op)) {
score += 40;
}
}
return { tool, score };
})
.filter((item) => item.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.tool);
this.logger.log(
`Tool search for "${query}" returned ${scored.length} results`,
);
return scored;
}
async getToolsByName(
names: string[],
context: ToolContext,
): Promise<ToolSet> {
const tools: ToolSet = {};
const rolePermissionConfig: RolePermissionConfig = {
intersectionOf: [context.roleId],
};
for (const name of names) {
const tool = await this.getToolByName(
name,
context.workspaceId,
rolePermissionConfig,
context.actorContext,
);
if (tool) {
tools[name] = tool;
}
}
return tools;
}
private async getToolByName(
name: string,
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
actorContext?: ActorMetadata,
): Promise<ToolSet[string] | null> {
const actionTool = this.getActionToolByName(name, workspaceId);
if (actionTool) {
return actionTool;
}
const workflowTool = await this.getWorkflowToolByName(
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,
},
);
}
}
@@ -6,10 +6,13 @@ 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';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
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 { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { ToolProviderService } from './services/tool-provider.service';
import { ToolRegistryService } from './services/tool-registry.service';
// NOTE: This module does NOT import WorkflowToolsModule to avoid circular dependency:
// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule
@@ -30,8 +33,10 @@ import { ToolProviderService } from './services/tool-provider.service';
ObjectMetadataModule,
FieldMetadataModule,
PermissionsModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [ToolProviderService],
exports: [ToolProviderService],
providers: [ToolProviderService, ToolRegistryService],
exports: [ToolProviderService, ToolRegistryService],
})
export class ToolProviderModule {}
@@ -0,0 +1,61 @@
import { z } from 'zod';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
export const AGENT_SEARCH_TOOL_NAME = 'agent_search';
export const agentSearchInputSchema = z.object({
input: z.object({
query: z.string().describe('What kind of expertise or help you need'),
limit: z
.number()
.optional()
.default(2)
.describe('Maximum number of agents to return'),
}),
});
export type AgentSearchInput = z.infer<typeof agentSearchInputSchema>['input'];
export type AgentSearchResult = {
agents: Array<{
name: string;
label: string;
expertise: string;
}>;
message: string;
};
export type AgentSearchFunction = (
query: string,
options: { limit: number },
) => Promise<AgentEntity[]>;
export const createAgentSearchTool = (searchAgents: AgentSearchFunction) => ({
description:
'Search for agent expertise/skills to help with specialized tasks. Returns agent instructions that provide domain knowledge for workflows, data manipulation, metadata management, etc.',
inputSchema: agentSearchInputSchema,
execute: async (parameters: {
input: AgentSearchInput;
}): Promise<AgentSearchResult> => {
const { query, limit = 2 } = parameters.input;
const agents = await searchAgents(query, { limit });
if (agents.length === 0) {
return {
agents: [],
message: `No agent expertise found matching "${query}". Try searching for: "workflow", "data", "metadata", "dashboard", or "research".`,
};
}
return {
agents: agents.map((agent) => ({
name: agent.name,
label: agent.label,
expertise: agent.prompt,
})),
message: `Found ${agents.length} agent(s) with relevant expertise. Their instructions are included above to help guide your approach.`,
};
},
});
@@ -0,0 +1,17 @@
export {
createLoadToolsTool,
LOAD_TOOLS_TOOL_NAME,
loadToolsInputSchema,
type LoadToolsInput,
type LoadToolsResult,
type DynamicToolStore,
} from './load-tools.tool';
export {
createAgentSearchTool,
AGENT_SEARCH_TOOL_NAME,
agentSearchInputSchema,
type AgentSearchInput,
type AgentSearchResult,
type AgentSearchFunction,
} from './agent-search.tool';
@@ -0,0 +1,77 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
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.',
),
}),
});
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>['input'];
export type LoadToolsResult = {
loaded: string[];
notFound: string[];
message: string;
};
export type DynamicToolStore = {
loadedTools: Set<string>;
};
export const createLoadToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
dynamicToolStore: DynamicToolStore,
onToolsLoaded: (toolNames: string[]) => Promise<void>,
) => ({
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;
const loaded: string[] = [];
const notFound: string[] = [];
const tools = await toolRegistry.getToolsByName(toolNames, context);
for (const name of toolNames) {
if (tools[name]) {
loaded.push(name);
dynamicToolStore.loadedTools.add(name);
} else {
notFound.push(name);
}
}
if (loaded.length > 0) {
await onToolsLoaded(loaded);
}
if (notFound.length > 0) {
return {
loaded,
notFound,
message: `Loaded ${loaded.length} tool(s). Could not find: ${notFound.join(', ')}. Check the tool catalog for correct names.`,
};
}
return {
loaded,
notFound: [],
message: `Successfully loaded ${loaded.length} tool(s): ${loaded.join(', ')}. These tools are now available for use.`,
};
},
});
@@ -1,7 +1,6 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
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';
@@ -11,6 +10,5 @@ export type ToolSpecification = {
rolePermissionConfig?: RolePermissionConfig;
actorContext?: ActorMetadata;
agent?: FlatAgentWithRoleId | null;
toolHints?: ToolHints;
wrapWithErrorContext?: boolean;
};
@@ -1,3 +1,9 @@
export type RecordReference = {
objectNameSingular: string;
recordId: string;
displayName: string;
};
export type ToolOutput<T = object> = {
success: boolean;
message: string;
@@ -6,4 +12,6 @@ export type ToolOutput<T = object> = {
status?: number;
statusText?: string;
headers?: Record<string, string>;
// Record references for linking to created/found records
recordReferences?: RecordReference[];
};