refactor(twenty-server): consolidate AI tool provider architecture (#16355)

## Summary

Consolidates the AI tool provider architecture by creating a single
`ToolProviderService` as the entry point for all tool generation. This
removes multiple intermediate services and simplifies the codebase.

## Changes

### New Architecture
- **`ToolProviderService`**: Single service for all tool generation
with:
  - `getTools(spec)` - Get tools by category with permissions
  - `getToolByType(type)` - Get specific tool for workflow execution
  
- **`ToolCategory` enum**: Declarative specification of tool types:
  - `DATABASE_CRUD` - Record CRUD operations
  - `ACTION` - HTTP requests, email sending, article search
  - `WORKFLOW` - Workflow management tools
  - `METADATA` - Object/field metadata tools
  - `NATIVE_MODEL` - Model-specific tools (e.g., web search)

- **`ToolSpecification` type**: Clean API for requesting tools with
permissions

### Removed
- `AiToolsModule` - No longer needed
- `ToolService` - Logic inlined into ToolProviderService
- `ToolAdapterService` - Logic inlined into ToolProviderService
- `ToolRegistryService` - Logic inlined into ToolProviderService

### Updated
- All consumers (agents, chat, MCP, workflows) now use
`ToolProviderService`
- Test files updated accordingly

## Stats
- **547 insertions, 1146 deletions** (net ~600 lines removed)
- 4 services deleted
- 1 module deleted

## Testing
- [x] Typecheck passes
- [x] Lint passes
This commit is contained in:
Félix Malfait
2025-12-06 06:51:47 +01:00
committed by GitHub
parent 43ed4963d6
commit 223082a4da
33 changed files with 588 additions and 1169 deletions
@@ -35,7 +35,6 @@ import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -47,7 +46,6 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
PermissionsModule,
AiBillingModule,
AiModelsModule,
AiToolsModule,
WorkspaceDomainsModule,
TypeOrmModule.forFeature([
BillingSubscriptionEntity,
@@ -56,7 +56,6 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module';
import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { SubscriptionsModule } from 'src/engine/subscriptions/subscriptions.module';
import { TrashCleanupModule } from 'src/engine/trash-cleanup/trash-cleanup.module';
@@ -130,7 +129,6 @@ import { FileModule } from './file/file.module';
}),
CacheStorageModule,
AiModelsModule,
AiToolsModule,
AiBillingModule,
ServerlessModule.forRootAsync({
useFactory: serverlessModuleFactory,
@@ -0,0 +1,7 @@
// Injection token for WorkflowToolWorkspaceService to break circular dependency:
// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule
// -> WorkflowRunnerModule -> WorkflowExecutorModule -> AiAgentActionModule
// -> AiAgentExecutionModule -> ToolProviderModule
export const WORKFLOW_TOOL_SERVICE_TOKEN = Symbol(
'WORKFLOW_TOOL_SERVICE_TOKEN',
);
@@ -0,0 +1,7 @@
export enum ToolCategory {
DATABASE_CRUD = 'DATABASE_CRUD',
ACTION = 'ACTION',
WORKFLOW = 'WORKFLOW',
METADATA = 'METADATA',
NATIVE_MODEL = 'NATIVE_MODEL',
}
@@ -0,0 +1,352 @@
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolSpecification } from 'src/engine/core-modules/tool-provider/types/tool-specification.type';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { 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 { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
// Type-only import to avoid circular dependency at file level
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
// Tool definition with optional permission flag
type ActionTool = {
tool: Tool;
flag?: PermissionFlagType;
};
@Injectable()
export class ToolProviderService {
private readonly logger = new Logger(ToolProviderService.name);
private readonly actionTools: Map<ToolType, ActionTool>;
constructor(
// Action tools (individual tools)
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchArticlesTool: SearchArticlesTool,
// Database CRUD tools
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
// Workflow tools - optional to avoid circular dependency with WorkflowExecutorModule.
// When used from workflow context, this will be null (and workflow tools aren't
// needed anyway since agents in workflows shouldn't create other workflows).
// When used from chat context, WorkflowToolsModule provides this service.
@Optional()
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
// Metadata tools
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
// Native model tools
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiModelRegistryService: AiModelRegistryService,
// Permissions
private readonly permissionsService: PermissionsService,
) {
this.actionTools = new Map([
[
ToolType.HTTP_REQUEST,
{
tool: this.httpTool,
flag: PermissionFlagType.HTTP_REQUEST_TOOL,
},
],
[
ToolType.SEND_EMAIL,
{
tool: this.sendEmailTool,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
},
],
[
ToolType.SEARCH_ARTICLES,
{
tool: this.searchArticlesTool,
// No permission flag - available to all agents
},
],
]);
}
// Get a specific tool by type (used by workflow executor)
getToolByType(toolType: ToolType): Tool {
const actionTool = this.actionTools.get(toolType);
if (!actionTool) {
throw new Error(`Unknown tool type: ${toolType}`);
}
return actionTool.tool;
}
async getTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
for (const category of spec.categories) {
const categoryTools = await this.getToolsForCategory(category, spec);
Object.assign(tools, categoryTools);
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${spec.categories.join(', ')}]`,
);
if (spec.wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return tools;
}
private async getToolsForCategory(
category: ToolCategory,
spec: ToolSpecification,
): Promise<ToolSet> {
switch (category) {
case ToolCategory.DATABASE_CRUD:
return this.getDatabaseTools(spec);
case ToolCategory.ACTION:
return this.getActionTools(spec);
case ToolCategory.WORKFLOW:
return this.getWorkflowTools(spec);
case ToolCategory.METADATA:
return this.getMetadataTools(spec);
case ToolCategory.NATIVE_MODEL:
return this.getNativeModelTools(spec);
default:
return {};
}
}
private async getDatabaseTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.rolePermissionConfig) {
return {};
}
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
return this.perObjectToolGenerator.generate(
{
workspaceId: spec.workspaceId,
rolePermissionConfig: spec.rolePermissionConfig,
actorContext: spec.actorContext,
},
[factory],
spec.toolHints,
);
}
private async getActionTools(spec: ToolSpecification): Promise<ToolSet> {
const tools: ToolSet = {};
for (const [toolType, { tool, flag }] of this.actionTools) {
if (!flag) {
// No permission flag - available to all
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input),
};
} else if (spec.rolePermissionConfig && spec.workspaceId) {
const hasPermission = await this.permissionsService.hasToolPermission(
spec.rolePermissionConfig,
spec.workspaceId,
flag,
);
if (hasPermission) {
tools[toolType.toLowerCase()] = {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input),
};
}
}
}
return tools;
}
private async getWorkflowTools(spec: ToolSpecification): Promise<ToolSet> {
// Workflow tools are optional - not available when called from workflow context
// to avoid circular dependencies (agents in workflows shouldn't create workflows)
if (!this.workflowToolService) {
return {};
}
if (!spec.rolePermissionConfig) {
return {};
}
const hasWorkflowPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (!hasWorkflowPermission) {
return {};
}
const workflowTools = this.workflowToolService.generateWorkflowTools(
spec.workspaceId,
spec.rolePermissionConfig,
);
const recordStepTools =
await this.workflowToolService.generateRecordStepConfiguratorTools(
spec.workspaceId,
spec.rolePermissionConfig,
spec.toolHints,
);
return { ...workflowTools, ...recordStepTools };
}
private async getMetadataTools(spec: ToolSpecification): Promise<ToolSet> {
if (spec.rolePermissionConfig) {
const hasDataModelPermission =
await this.permissionsService.checkRolesPermissions(
spec.rolePermissionConfig,
spec.workspaceId,
PermissionFlagType.DATA_MODEL,
);
if (!hasDataModelPermission) {
return {};
}
}
const objectMetadataTools = this.objectMetadataToolsFactory.generateTools(
spec.workspaceId,
);
const fieldMetadataTools = this.fieldMetadataToolsFactory.generateTools(
spec.workspaceId,
);
return { ...objectMetadataTools, ...fieldMetadataTools };
}
private async getNativeModelTools(spec: ToolSpecification): Promise<ToolSet> {
if (!spec.agent) {
return {};
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(spec.agent);
return this.agentModelConfigService.getNativeModelTools(
registeredModel,
spec.agent,
);
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -0,0 +1,37 @@
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 { 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 { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { ToolProviderService } from './services/tool-provider.service';
// NOTE: This module does NOT import WorkflowToolsModule to avoid circular dependency:
// ToolProviderModule -> WorkflowToolsModule -> WorkflowTriggerModule
// -> WorkflowRunnerModule -> WorkflowExecutorModule -> AiAgentActionModule
// -> AiAgentExecutionModule -> ToolProviderModule
//
// Instead, WorkflowToolWorkspaceService is an optional dependency that must be
// provided by the importing module (e.g., AiChatModule imports WorkflowToolsModule).
@Module({
imports: [
ToolModule,
ToolGeneratorModule,
RecordCrudModule,
AiModelsModule,
// forwardRef needed: AiAgentExecutionModule imports ToolProviderModule
forwardRef(() => AiAgentExecutionModule),
ObjectMetadataModule,
FieldMetadataModule,
PermissionsModule,
],
providers: [ToolProviderService],
exports: [ToolProviderService],
})
export class ToolProviderModule {}
@@ -0,0 +1,16 @@
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';
export type ToolSpecification = {
workspaceId: string;
categories: ToolCategory[];
rolePermissionConfig?: RolePermissionConfig;
actorContext?: ActorMetadata;
agent?: FlatAgentWithRoleId | null;
toolHints?: ToolHints;
wrapWithErrorContext?: boolean;
};
@@ -1,4 +1,5 @@
export enum ToolType {
HTTP_REQUEST = 'HTTP_REQUEST',
SEND_EMAIL = 'SEND_EMAIL',
SEARCH_ARTICLES = 'SEARCH_ARTICLES',
}
@@ -1,59 +0,0 @@
import { Injectable } from '@nestjs/common';
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 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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
@Injectable()
export class ToolRegistryService {
private readonly toolFactories: Map<ToolType, () => Tool>;
constructor(
private readonly sendEmailTool: SendEmailTool,
private readonly twentyConfigService: TwentyConfigService,
) {
this.toolFactories = new Map<ToolType, () => Tool>([
[
ToolType.HTTP_REQUEST,
() => {
const httpTool = new HttpTool(twentyConfigService);
return {
description: httpTool.description,
inputSchema: httpTool.inputSchema,
execute: (params) => httpTool.execute(params),
flag: PermissionFlagType.HTTP_REQUEST_TOOL,
};
},
],
[
ToolType.SEND_EMAIL,
() => ({
description: this.sendEmailTool.description,
inputSchema: this.sendEmailTool.inputSchema,
execute: (params) =>
this.sendEmailTool.execute(params as SendEmailInput),
flag: PermissionFlagType.SEND_EMAIL_TOOL,
}),
],
]);
}
getTool(toolType: ToolType): Tool {
const factory = this.toolFactories.get(toolType);
if (!factory) {
throw new Error(`Unknown tool type: ${toolType}`);
}
return factory();
}
getAllToolTypes(): ToolType[] {
return Array.from(this.toolFactories.keys());
}
}
@@ -3,7 +3,6 @@ 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 { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
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';
@@ -15,7 +14,7 @@ import { MessagingImportManagerModule } from 'src/modules/messaging/message-impo
TypeOrmModule.forFeature([FileEntity]),
FileModule,
],
providers: [HttpTool, SendEmailTool, SearchArticlesTool, ToolRegistryService],
exports: [ToolRegistryService],
providers: [HttpTool, SendEmailTool, SearchArticlesTool],
exports: [HttpTool, SendEmailTool, SearchArticlesTool],
})
export class ToolModule {}
@@ -5,8 +5,7 @@ import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/featu
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
import { DuplicateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow-version-step-input.dto';
@@ -47,7 +46,7 @@ export class WorkflowVersionStepResolver {
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
private readonly workflowRunnerWorkspaceService: WorkflowRunnerWorkspaceService,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly toolRegistryService: ToolRegistryService,
private readonly httpTool: HttpTool,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -153,7 +152,7 @@ export class WorkflowVersionStepResolver {
@Args('input')
{ url, method, headers, body }: TestHttpRequestInput,
): Promise<TestHttpRequestOutput> {
return this.toolRegistryService.getTool(ToolType.HTTP_REQUEST).execute({
return this.httpTool.execute({
url,
method,
headers,