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:
+12
@@ -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) {
|
||||
|
||||
+12
@@ -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}`);
|
||||
|
||||
+12
@@ -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) {
|
||||
|
||||
+46
@@ -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');
|
||||
};
|
||||
+8
-54
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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 };
|
||||
|
||||
+565
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+7
-2
@@ -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 {}
|
||||
|
||||
+61
@@ -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.`,
|
||||
};
|
||||
},
|
||||
});
|
||||
-2
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user