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
@@ -9,9 +9,7 @@ import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-exe
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.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 { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@@ -19,14 +17,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity]),
AiToolsModule,
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
MetricsModule,
UserRoleModule,
ObjectMetadataModule,
FieldMetadataModule,
ToolProviderModule,
],
controllers: [McpCoreController, McpMetadataController],
exports: [McpProtocolService],
@@ -10,8 +10,8 @@ import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.ser
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@@ -19,7 +19,7 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
describe('McpProtocolService', () => {
let service: McpProtocolService;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let toolService: jest.Mocked<ToolService>;
let toolProviderService: jest.Mocked<ToolProviderService>;
let userRoleService: jest.Mocked<UserRoleService>;
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
@@ -34,8 +34,8 @@ describe('McpProtocolService', () => {
isFeatureEnabled: jest.fn(),
};
const mockToolService = {
listTools: jest.fn(),
const mockToolProviderService = {
getTools: jest.fn(),
};
const mockUserRoleService = {
@@ -60,8 +60,8 @@ describe('McpProtocolService', () => {
useValue: mockFeatureFlagService,
},
{
provide: ToolService,
useValue: mockToolService,
provide: ToolProviderService,
useValue: mockToolProviderService,
},
{
provide: UserRoleService,
@@ -82,7 +82,7 @@ describe('McpProtocolService', () => {
service = module.get<McpProtocolService>(McpProtocolService);
featureFlagService = module.get(FeatureFlagService);
toolService = module.get(ToolService);
toolProviderService = module.get(ToolProviderService);
userRoleService = module.get(UserRoleService);
mcpToolExecutorService = module.get(McpToolExecutorService);
});
@@ -221,7 +221,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolService.listTools.mockResolvedValue(mockToolsMap);
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -275,7 +275,7 @@ describe('McpProtocolService', () => {
testTool: mockTool,
};
toolService.listTools.mockResolvedValue(mockToolsMap);
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
const mockToolCallResponse = {
id: '123',
@@ -309,10 +309,7 @@ describe('McpProtocolService', () => {
});
expect(result).toEqual(mockToolCallResponse);
expect(toolService.listTools).toHaveBeenCalledWith(
{ unionOf: [mockAdminRoleId] },
mockWorkspace.id,
);
expect(toolProviderService.getTools).toHaveBeenCalled();
});
it('should handle tools listing', async () => {
@@ -326,7 +323,7 @@ describe('McpProtocolService', () => {
},
};
toolService.listTools.mockResolvedValue(mockToolsMap);
toolProviderService.getTools.mockResolvedValue(mockToolsMap);
const mockToolsListingResponse = {
id: '123',
@@ -392,7 +389,7 @@ describe('McpProtocolService', () => {
it('should handle error when tool is not found', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(true);
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
toolService.listTools.mockResolvedValue({});
toolProviderService.getTools.mockResolvedValue({});
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
@@ -8,16 +8,15 @@ 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 { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
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';
@Injectable()
export class MCPMetadataService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly toolProvider: ToolProviderService,
private readonly metricsService: MetricsService,
) {}
@@ -50,18 +49,19 @@ export class MCPMetadataService {
});
}
getTools(workspaceId: string): ToolSet {
return {
...this.objectMetadataToolsFactory.generateTools(workspaceId),
...this.fieldMetadataToolsFactory.generateTools(workspaceId),
};
async getTools(workspaceId: string): Promise<ToolSet> {
return this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.METADATA],
wrapWithErrorContext: false,
});
}
async handleToolCall(
request: Request,
workspaceId: string,
): Promise<Parameters<typeof wrapJsonRpcResponse>[1]> {
const tools = this.getTools(workspaceId);
const tools = await this.getTools(workspaceId);
const toolName = request.body.params.name as keyof typeof tools;
const tool = tools[toolName];
@@ -99,8 +99,8 @@ export class MCPMetadataService {
};
}
listTools(request: Request, workspaceId: string) {
const tools = this.getTools(workspaceId);
async listTools(request: Request, workspaceId: string) {
const tools = await this.getTools(workspaceId);
const toolsArray = Object.entries(tools)
.filter(([, def]) => !!def.inputSchema)
@@ -167,7 +167,7 @@ export class MCPMetadataService {
}
if (request.body.method === 'tools/list') {
return this.listTools(request, workspace.id);
return await this.listTools(request, workspace.id);
}
if (request.body.method === 'prompts/list') {
@@ -8,8 +8,9 @@ import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-exe
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@@ -18,7 +19,7 @@ import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata
export class McpProtocolService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolService: ToolService,
private readonly toolProvider: ToolProviderService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
@InjectRepository(RoleEntity)
@@ -128,10 +129,12 @@ export class McpProtocolService {
apiKey,
);
const toolSet = await this.toolService.listTools(
{ unionOf: [roleId] },
workspace.id,
);
const toolSet = await this.toolProvider.getTools({
workspaceId: workspace.id,
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
rolePermissionConfig: { unionOf: [roleId] },
wrapWithErrorContext: false,
});
if (method === 'tools/call' && params) {
return await this.mcpToolExecutorService.handleToolCall(
@@ -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,
@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.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 { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -19,7 +19,6 @@ import { AgentTurnEntity } from './entities/agent-turn.entity';
import { AgentActorContextService } from './services/agent-actor-context.service';
import { AgentAsyncExecutorService } from './services/agent-async-executor.service';
import { AgentExecutionService } from './services/agent-execution.service';
import { AgentModelConfigService } from './services/agent-model-config.service';
import { AgentPlanExecutorService } from './services/agent-plan-executor.service';
import { AgentToolGeneratorService } from './services/agent-tool-generator.service';
@@ -27,13 +26,13 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
imports: [
AiBillingModule,
AiModelsModule,
AiToolsModule,
AiAgentModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
UserRoleModule,
PermissionsModule,
WorkspaceCacheModule,
forwardRef(() => ToolProviderModule),
TypeOrmModule.forFeature([
AgentEntity,
AgentMessageEntity,
@@ -46,7 +45,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
AgentAsyncExecutorService,
AgentExecutionService,
AgentToolGeneratorService,
AgentModelConfigService,
AgentActorContextService,
AgentPlanExecutorService,
],
@@ -56,7 +54,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentActorContextService,
AgentModelConfigService,
TypeOrmModule.forFeature([
AgentMessageEntity,
AgentMessagePartEntity,
@@ -11,6 +11,8 @@ import {
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
@@ -22,12 +24,9 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
// Agent execution within workflows uses database and action tools only.
// Workflow tools are intentionally excluded to avoid circular dependencies
@@ -39,8 +38,7 @@ export class AgentAsyncExecutorService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
private readonly toolProvider: ToolProviderService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
) {}
@@ -63,12 +61,11 @@ export class AgentAsyncExecutorService {
return [];
}
private async getToolsForWorkflowExecution(
private async getEffectiveRolePermissionConfig(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
rolePermissionConfig?: RolePermissionConfig,
): Promise<ToolSet> {
): Promise<RolePermissionConfig | undefined> {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId,
@@ -80,37 +77,15 @@ export class AgentAsyncExecutorService {
const agentRoleId = roleTarget?.roleId;
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
// Combine role IDs from config and agent
const allRoleIds = agentRoleId
? [...new Set([...configRoleIds, agentRoleId])]
: configRoleIds;
if (allRoleIds.length === 0) {
// No role context - return basic action tools only
return this.toolAdapterService.getTools();
return undefined;
}
const effectiveRoleContext: RolePermissionConfig = {
intersectionOf: allRoleIds,
};
// Get database CRUD tools
const databaseTools = await this.toolService.listTools(
effectiveRoleContext,
workspaceId,
actorContext,
);
// Get action tools (send email, http request, etc.)
const actionTools = await this.toolAdapterService.getTools(
effectiveRoleContext,
workspaceId,
);
return {
...databaseTools,
...actionTools,
};
return { intersectionOf: allRoleIds };
}
async executeAgent({
@@ -132,23 +107,28 @@ export class AgentAsyncExecutorService {
let providerOptions = {};
if (agent) {
tools = await this.getToolsForWorkflowExecution(
const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig(
agent.id,
agent.workspaceId,
actorContext,
rolePermissionConfig,
);
// Add native model tools (web search, etc.) if configured
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent as unknown as Parameters<
typeof this.agentModelConfigService.getNativeModelTools
>[1],
);
tools = { ...tools, ...nativeModelTools };
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
// Workflow tools are excluded to prevent circular dependencies
tools = await this.toolProvider.getTools({
workspaceId: agent.workspaceId,
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
rolePermissionConfig: effectiveRoleConfig,
actorContext,
agent: agent as unknown as Parameters<
typeof this.toolProvider.getTools
>[0]['agent'],
wrapWithErrorContext: false,
});
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -32,9 +32,9 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AgentActorContextService } from './agent-actor-context.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
// Re-export for backward compatibility
@@ -1,30 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { Repository } from 'typeorm';
import type { ActorMetadata } from 'twenty-shared/types';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import type { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
@Injectable()
export class AgentToolGeneratorService {
private readonly logger = new Logger(AgentToolGeneratorService.name);
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
private readonly searchArticlesTool: SearchArticlesTool,
) {}
constructor(private readonly toolProvider: ToolProviderService) {}
// Generates base tools for chat context (DATABASE_CRUD and ACTION)
// Additional tools (WORKFLOW, METADATA) are provided via additionalTools
// from ChatToolsProviderService to avoid circular dependencies
async generateToolsForAgent(
agentId: string,
workspaceId: string,
@@ -32,142 +24,24 @@ export class AgentToolGeneratorService {
roleIds?: string[],
toolHints?: ToolHints,
): Promise<ToolSet> {
let tools: ToolSet = {};
try {
const agent = await this.agentRepository.findOne({
where: { id: agentId },
});
if (agent?.standardId === HELPER_AGENT.standardId) {
return this.wrapToolsWithErrorContext(this.getHelperAgentTools());
}
const actionTools = await this.toolAdapterService.getTools();
tools = { ...actionTools };
if (!roleIds) {
return this.wrapToolsWithErrorContext(tools);
}
// Workflow tools are NOT generated here to avoid circular dependencies
// They are provided via additionalTools from ChatToolsProviderService in the chat context
const databaseTools = await this.toolService.listTools(
{ intersectionOf: roleIds },
return await this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
rolePermissionConfig: roleIds ? { intersectionOf: roleIds } : undefined,
actorContext,
toolHints,
);
tools = { ...tools, ...databaseTools };
const roleActionTools = await this.toolAdapterService.getTools(
{ intersectionOf: roleIds },
workspaceId,
);
tools = { ...tools, ...roleActionTools };
wrapWithErrorContext: true,
});
} catch (toolError) {
const errorMessage =
toolError instanceof Error ? toolError.message : 'Unknown error';
this.logger.warn(
`Failed to generate tools for agent ${agentId}: ${toolError.message}. Proceeding without tools.`,
`Failed to generate tools for agent ${agentId}: ${errorMessage}. Proceeding without tools.`,
);
return {};
}
return this.wrapToolsWithErrorContext(tools);
}
private getHelperAgentTools(): ToolSet {
const tools: ToolSet = {
search_articles: {
description: this.searchArticlesTool.description,
inputSchema: this.searchArticlesTool.inputSchema,
execute: async (params) =>
this.searchArticlesTool.execute(params.input),
},
};
this.logger.log('Generated search_articles tool for Helper agent');
return tools;
}
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';
}
}
@@ -4,7 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
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 { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { AiChatRouterService } from './ai-chat-router.service';
@@ -16,7 +15,6 @@ import { AiChatRouterStrategyDeciderService } from './services/ai-chat-router-st
imports: [
TypeOrmModule.forFeature([AgentEntity, WorkspaceEntity]),
AiModelsModule,
AiToolsModule,
ObjectMetadataModule,
],
providers: [
@@ -7,16 +7,17 @@ import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.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 { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatController } from './controllers/agent-chat.controller';
@@ -47,12 +48,9 @@ import { ChatToolsProviderService } from './services/chat-tools-provider.service
TokenModule,
UserWorkspaceModule,
AiBillingModule,
// Provides WorkflowToolWorkspaceService for ChatToolsProviderService
// Workflow tools are only available in chat context, not in workflow executor (to avoid circular deps)
ToolProviderModule,
// WorkflowToolsModule provides workflow tools for chat context
WorkflowToolsModule,
// Provides metadata tools factories for ChatToolsProviderService
ObjectMetadataModule,
FieldMetadataModule,
],
controllers: [AgentChatController],
providers: [
@@ -62,6 +60,11 @@ import { ChatToolsProviderService } from './services/chat-tools-provider.service
AgentChatRoutingService,
AgentTitleGenerationService,
ChatToolsProviderService,
// Provide WorkflowToolWorkspaceService via token for ToolProviderService
{
provide: WORKFLOW_TOOL_SERVICE_TOKEN,
useExisting: WorkflowToolWorkspaceService,
},
],
exports: [
AgentChatService,
@@ -1,113 +1,37 @@
/* eslint-disable @nx/workspace-inject-workspace-repository */
import { Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
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';
import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@Injectable()
export class ChatToolsProviderService {
private readonly logger = new Logger(ChatToolsProviderService.name);
constructor(
private readonly workflowToolService: WorkflowToolWorkspaceService,
private readonly permissionsService: PermissionsService,
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
) {}
constructor(private readonly toolProvider: ToolProviderService) {}
// Consolidates all permission-based tools for the chat context
// Provides additional tools for the chat context (WORKFLOW and METADATA)
// These tools are NOT available in the workflow executor context to prevent circular dependencies
// Base tools (DATABASE_CRUD, ACTION) are provided by AgentToolGeneratorService
async getChatTools(
workspaceId: string,
roleIds: string[],
toolHints?: ToolHints,
): Promise<ToolSet> {
const [workflowTools, metadataTools] = await Promise.all([
this.getWorkflowTools(workspaceId, roleIds, toolHints),
this.getMetadataTools(workspaceId, roleIds),
]);
const allTools = { ...workflowTools, ...metadataTools };
const tools = await this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.WORKFLOW, ToolCategory.METADATA],
rolePermissionConfig: { intersectionOf: roleIds },
toolHints,
wrapWithErrorContext: false,
});
this.logger.log(
`Generated ${Object.keys(allTools).length} total chat tools (workflow: ${Object.keys(workflowTools).length}, metadata: ${Object.keys(metadataTools).length})`,
`Generated ${Object.keys(tools).length} additional chat tools (workflow + metadata)`,
);
return allTools;
}
// Provides workflow-specific tools for the chat context
// These tools are NOT available in the workflow executor context to prevent circular dependencies
private async getWorkflowTools(
workspaceId: string,
roleIds: string[],
toolHints?: ToolHints,
): Promise<ToolSet> {
const rolePermissionConfig = { intersectionOf: roleIds };
const hasWorkflowPermission =
await this.permissionsService.checkRolesPermissions(
rolePermissionConfig,
workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (!hasWorkflowPermission) {
this.logger.log(
'User does not have workflow permissions, skipping workflow tools',
);
return {};
}
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
rolePermissionConfig,
);
const recordStepTools =
await this.workflowToolService.generateRecordStepConfiguratorTools(
workspaceId,
rolePermissionConfig,
toolHints,
);
return { ...workflowTools, ...recordStepTools };
}
// Provides metadata tools for managing objects and fields in the data model
private async getMetadataTools(
workspaceId: string,
roleIds: string[],
): Promise<ToolSet> {
const rolePermissionConfig = { intersectionOf: roleIds };
const hasDataModelPermission =
await this.permissionsService.checkRolesPermissions(
rolePermissionConfig,
workspaceId,
PermissionFlagType.DATA_MODEL,
);
if (!hasDataModelPermission) {
this.logger.log(
'User does not have data model permissions, skipping metadata tools',
);
return {};
}
const objectMetadataTools =
this.objectMetadataToolsFactory.generateTools(workspaceId);
const fieldMetadataTools =
this.fieldMetadataToolsFactory.generateTools(workspaceId);
return { ...objectMetadataTools, ...fieldMetadataTools };
return tools;
}
}
@@ -1,11 +1,12 @@
import { Global, Module } from '@nestjs/common';
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 { AiService } from 'src/engine/metadata-modules/ai/ai-models/services/ai.service';
@Global()
@Module({
providers: [AiModelRegistryService, AiService],
exports: [AiModelRegistryService, AiService],
providers: [AiModelRegistryService, AiService, AgentModelConfigService],
exports: [AiModelRegistryService, AiService, AgentModelConfigService],
})
export class AiModelsModule {}
@@ -1,44 +0,0 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileModule } from 'src/engine/core-modules/file/file.module';
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 { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { MessagingModule } from 'src/modules/messaging/messaging.module';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity, FileEntity]),
FileModule,
TokenModule,
FeatureFlagModule,
RecordCrudModule,
ToolGeneratorModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheStorageModule,
UserRoleModule,
TwentyORMModule,
MessagingModule,
PermissionsModule,
ToolModule,
WorkspaceCacheModule,
],
providers: [ToolService, ToolAdapterService, SearchArticlesTool],
exports: [ToolService, ToolAdapterService, SearchArticlesTool],
})
export class AiToolsModule {}
@@ -1,172 +0,0 @@
import { Test } from '@nestjs/testing';
import { jsonSchema } from 'ai';
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 { 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 { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
const createMockToolRegistry = () => ({
getAllToolTypes: jest.fn(),
getTool: jest.fn(),
});
const createMockPermissions = () => ({
hasToolPermission: jest.fn<
Promise<boolean>,
[RolePermissionConfig, string, PermissionFlagType]
>(),
});
describe('ToolAdapterService', () => {
let mockRegistry: ReturnType<typeof createMockToolRegistry>;
let mockPermissions: ReturnType<typeof createMockPermissions>;
let service: ToolAdapterService;
// Shared tools
const unflaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
}));
const unflaggedTool: Tool = {
description: 'HTTP Request tool',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: unflaggedToolExecute,
};
const flaggedToolExecute = jest.fn(async (input: ToolInput) => ({
success: true,
message: 'Tool executed successfully',
result: { sent: input },
}));
const flaggedTool: Tool = {
description: 'Send Email tool',
inputSchema: jsonSchema({ type: 'object', properties: {} }),
execute: flaggedToolExecute,
flag: PermissionFlagType.SEND_EMAIL_TOOL,
};
beforeEach(async () => {
jest.clearAllMocks();
mockRegistry = createMockToolRegistry();
mockPermissions = createMockPermissions();
// Setup mock tool responses
mockRegistry.getAllToolTypes.mockReturnValue([
ToolType.HTTP_REQUEST,
ToolType.SEND_EMAIL,
]);
mockRegistry.getTool.mockImplementation((type: ToolType) => {
if (type === ToolType.HTTP_REQUEST) return unflaggedTool;
if (type === ToolType.SEND_EMAIL) return flaggedTool;
throw new Error('Tool not found in mock');
});
const moduleRef = await Test.createTestingModule({
providers: [
ToolAdapterService,
{
provide: ToolRegistryService,
useValue: mockRegistry,
},
{
provide: PermissionsService,
useValue: mockPermissions,
},
],
}).compile();
service = moduleRef.get(ToolAdapterService);
});
it('should include unflagged tools regardless of role/workspace', async () => {
const toolsNoContext = await service.getTools();
expect(Object.keys(toolsNoContext)).toContain('http_request');
const toolsWithPartialContext = await service.getTools({
unionOf: ['role-1'],
});
expect(Object.keys(toolsWithPartialContext)).toContain('http_request');
});
it('should not include flagged tools when role/workspace are missing', async () => {
const toolsNoContext = await service.getTools();
expect(Object.keys(toolsNoContext)).not.toContain('send_email');
const toolsRoleOnly = await service.getTools({
unionOf: ['role-1'],
});
expect(Object.keys(toolsRoleOnly)).not.toContain('send_email');
const toolsWorkspaceOnly = await service.getTools(undefined, 'ws-1');
expect(Object.keys(toolsWorkspaceOnly)).not.toContain('send_email');
});
it('should include flagged tools when permission is granted', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(true);
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
expect(mockPermissions.hasToolPermission).toHaveBeenCalledWith(
{ unionOf: ['role-1'] },
'ws-1',
PermissionFlagType.SEND_EMAIL_TOOL,
);
expect(Object.keys(tools)).toContain('send_email');
});
it('should exclude flagged tools when permission is denied', async () => {
mockPermissions.hasToolPermission.mockResolvedValueOnce(false);
const tools = await service.getTools({ unionOf: ['role-1'] }, 'ws-1');
expect(Object.keys(tools)).not.toContain('send_email');
});
it('should lowercase tool type keys in the returned ToolSet', async () => {
const tools = await service.getTools();
const keys = Object.keys(tools);
expect(keys).toContain('http_request');
expect(keys).not.toContain(ToolType.HTTP_REQUEST); // ensure enum raw value not used as-is
});
it('should forward execute input correctly and return underlying result', async () => {
const tools = await service.getTools();
const input = { url: 'https://example.com', method: 'GET' } as ToolInput;
const result = await tools['http_request'].execute?.(
{ input },
{
toolCallId: 'test-tool-call-id',
messages: [
{
role: 'user',
content: 'content',
},
],
},
);
// Ensure wrapper forwards only parameters.input
expect(unflaggedToolExecute).toHaveBeenCalledWith(input);
expect(result).toEqual({
success: true,
message: 'Tool executed successfully',
result: { echoed: input },
});
});
});
@@ -1,138 +0,0 @@
import { Test } from '@nestjs/testing';
import { FieldActorSource } from 'twenty-shared/types';
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 { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
describe('ToolService', () => {
const workspaceId = 'ws_1';
const roleId = 'role_1';
let service: ToolService;
let perObjectToolGenerator: PerObjectToolGeneratorService;
const mockTools = {
create_testObject: {
description: 'Create a test object',
inputSchema: {},
execute: jest.fn(),
},
update_testObject: {
description: 'Update a test object',
inputSchema: {},
execute: jest.fn(),
},
find_testObject: {
description: 'Find test objects',
inputSchema: {},
execute: jest.fn(),
},
soft_delete_testObject: {
description: 'Soft delete a test object',
inputSchema: {},
execute: jest.fn(),
},
};
beforeEach(async () => {
jest.resetAllMocks();
const moduleRef = await Test.createTestingModule({
providers: [
ToolService,
{
provide: PerObjectToolGeneratorService,
useValue: {
generate: jest.fn().mockResolvedValue(mockTools),
},
},
{
provide: CreateRecordService,
useValue: { execute: jest.fn() },
},
{
provide: UpdateRecordService,
useValue: { execute: jest.fn() },
},
{
provide: DeleteRecordService,
useValue: { execute: jest.fn() },
},
{
provide: FindRecordsService,
useValue: { execute: jest.fn() },
},
],
}).compile();
service = moduleRef.get(ToolService);
perObjectToolGenerator = moduleRef.get(PerObjectToolGeneratorService);
});
describe('listTools', () => {
it('should call perObjectToolGenerator.generate with correct parameters', async () => {
const tools = await service.listTools({ unionOf: [roleId] }, workspaceId);
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
}),
expect.any(Array),
undefined,
);
expect(tools).toBe(mockTools);
});
it('should pass toolHints to perObjectToolGenerator.generate', async () => {
const toolHints: ToolHints = {
relevantObjects: ['company', 'person'],
operations: ['create', 'find'],
};
await service.listTools(
{ unionOf: [roleId] },
workspaceId,
undefined,
toolHints,
);
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
}),
expect.any(Array),
toolHints,
);
});
it('should pass actorContext to perObjectToolGenerator.generate', async () => {
const actorContext = {
source: FieldActorSource.API,
workspaceMemberId: 'member_1',
name: 'Test User',
context: {},
};
await service.listTools({ unionOf: [roleId] }, workspaceId, actorContext);
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
actorContext,
}),
expect.any(Array),
undefined,
);
});
});
});
@@ -1,54 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
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 { type PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ToolAdapterService {
constructor(
private readonly toolRegistry: ToolRegistryService,
private readonly permissionsService: PermissionsService,
) {}
async getTools(
rolePermissionConfig?: RolePermissionConfig,
workspaceId?: string,
): Promise<ToolSet> {
const tools: ToolSet = {};
for (const toolType of this.toolRegistry.getAllToolTypes()) {
const tool = this.toolRegistry.getTool(toolType);
if (!tool.flag) {
tools[toolType.toLowerCase()] = this.createToolSet(tool);
} else if (rolePermissionConfig && workspaceId) {
const hasPermission = await this.permissionsService.hasToolPermission(
rolePermissionConfig,
workspaceId,
tool.flag as PermissionFlagType,
);
if (hasPermission) {
tools[toolType.toLowerCase()] = this.createToolSet(tool);
}
}
}
return tools;
}
private createToolSet(tool: Tool) {
return {
description: tool.description,
inputSchema: tool.inputSchema,
execute: async (parameters: { input: ToolInput }) =>
tool.execute(parameters.input),
};
}
}
@@ -1,58 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
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,
type DirectRecordToolsDeps,
} 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 { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@Injectable()
export class ToolService {
private readonly directRecordToolsDeps: DirectRecordToolsDeps;
constructor(
private readonly perObjectToolGenerator: PerObjectToolGeneratorService,
createRecordService: CreateRecordService,
updateRecordService: UpdateRecordService,
deleteRecordService: DeleteRecordService,
findRecordsService: FindRecordsService,
) {
this.directRecordToolsDeps = {
createRecordService,
updateRecordService,
deleteRecordService,
findRecordsService,
};
}
// Generates AI tools for database operations based on workspace objects and permissions
async listTools(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
actorContext?: ActorMetadata,
toolHints?: ToolHints,
): Promise<ToolSet> {
const directRecordToolsFactory = createDirectRecordToolsFactory(
this.directRecordToolsDeps,
);
return this.perObjectToolGenerator.generate(
{
workspaceId,
rolePermissionConfig,
actorContext,
},
[directRecordToolsFactory],
toolHints,
);
}
}
@@ -52,7 +52,6 @@ export const SEARCH_FIELDS_FOR_COMPANY: FieldTypeAndNameMetadata[] = [
@WorkspaceEntity({
standardId: STANDARD_OBJECT_IDS.company,
namePlural: 'companies',
labelSingular: msg`Company`,
labelPlural: msg`Companies`,
@@ -4,16 +4,27 @@ import { resolveInput } from 'twenty-shared/utils';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
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 { 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 { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor/types/workflow-action-input';
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
@Injectable()
export class ToolExecutorWorkflowAction implements WorkflowAction {
constructor(private readonly toolRegistry: ToolRegistryService) {}
private readonly toolsByActionType: Map<WorkflowActionType, Tool>;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
) {
this.toolsByActionType = new Map<WorkflowActionType, Tool>([
[WorkflowActionType.HTTP_REQUEST, this.httpTool],
[WorkflowActionType.SEND_EMAIL, this.sendEmailTool],
]);
}
async execute({
currentStepId,
@@ -26,20 +37,10 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
throw new Error('Step not found');
}
const toolType = this.mapWorkflowActionTypeToToolType(step.type);
if (!toolType) {
throw new Error(
`No tool mapping found for workflow action type: ${step.type}`,
);
}
const tool = this.toolRegistry.getTool(toolType);
const tool = this.toolsByActionType.get(step.type);
if (!tool) {
throw new Error(
`Tool for action type ${step.type} not found in registry`,
);
throw new Error(`No tool found for workflow action type: ${step.type}`);
}
const toolInput = resolveInput(step.settings.input, context) as ToolInput;
@@ -51,15 +52,4 @@ export class ToolExecutorWorkflowAction implements WorkflowAction {
error: toolOutput.error,
};
}
private mapWorkflowActionTypeToToolType(
actionType: WorkflowActionType,
): ToolType | null {
const mapping: Partial<Record<WorkflowActionType, ToolType>> = {
[WorkflowActionType.HTTP_REQUEST]: ToolType.HTTP_REQUEST,
[WorkflowActionType.SEND_EMAIL]: ToolType.SEND_EMAIL,
};
return mapping[actionType] || null;
}
}
@@ -1,7 +1,5 @@
import { type ToolSet } from 'ai';
import { fromObjectMetadataEntityToFlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util';
import {
type AgentToolTestContext,
createAgentToolTestModule,
@@ -68,49 +66,15 @@ describe('AgentToolGeneratorService Integration', () => {
describe('Tool Generation', () => {
it('should generate complete tool set for agent with full permissions', async () => {
// Arrange
const roleWithFullPermissions = {
...context.testRole,
canDestroyAllObjectRecords: true,
};
jest
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([roleWithFullPermissions]);
jest
.spyOn(context.workspaceCacheService, 'getOrRecompute')
.mockResolvedValue({
rolesPermissions: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
},
},
} as any);
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([
fromObjectMetadataEntityToFlatObjectMetadata(
context.testObjectMetadata,
),
]);
// Configure perObjectToolGeneratorService to return the expected tools
jest
.spyOn(context.perObjectToolGeneratorService, 'generate')
.mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
}),
);
// Configure toolProviderService to return the expected tools
jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
}),
);
// Act
const tools = await context.agentToolService.generateToolsForAgent(
@@ -132,44 +96,15 @@ describe('AgentToolGeneratorService Integration', () => {
});
it('should generate read-only tools for agent with read permissions only', async () => {
// Arrange
jest
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([context.testRole]);
jest
.spyOn(context.workspaceCacheService, 'getOrRecompute')
.mockResolvedValue({
rolesPermissions: {
[context.testRoleId]: {
[context.testObjectMetadata.id]: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
},
},
} as any);
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([
fromObjectMetadataEntityToFlatObjectMetadata(
context.testObjectMetadata,
),
]);
// Configure perObjectToolGeneratorService to return read-only tools
jest
.spyOn(context.perObjectToolGeneratorService, 'generate')
.mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: false,
canUpdate: false,
canDelete: false,
}),
);
// Configure toolProviderService to return read-only tools
jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: false,
canUpdate: false,
canDelete: false,
}),
);
// Act
const tools = await context.agentToolService.generateToolsForAgent(
@@ -189,6 +124,9 @@ describe('AgentToolGeneratorService Integration', () => {
});
it('should return no tool for agent without role', async () => {
// Configure toolProviderService to return empty tools when no role
jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue({});
// Act
const tools = await context.agentToolService.generateToolsForAgent(
context.testAgentId,
@@ -200,49 +138,16 @@ describe('AgentToolGeneratorService Integration', () => {
});
it('should filter out workflow-run objects', async () => {
// Arrange
const workflowObject = {
...context.testObjectMetadata,
nameSingular: 'workflow',
namePlural: 'workflows',
};
jest
.spyOn(context.roleRepository, 'find')
.mockResolvedValue([context.testRole]);
jest
.spyOn(context.workspaceCacheService, 'getOrRecompute')
.mockResolvedValue({
rolesPermissions: {
[context.testRoleId]: {
[workflowObject.id]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
},
},
},
} as any);
jest
.spyOn(context.objectMetadataService, 'findManyWithinWorkspace')
.mockResolvedValue([
fromObjectMetadataEntityToFlatObjectMetadata(workflowObject),
]);
// Note: workflow objects are filtered out by PerObjectToolGeneratorService,
// Note: workflow objects are filtered out by ToolProviderService,
// so the mock returns tools for testObject (non-workflow) to simulate this behavior
jest
.spyOn(context.perObjectToolGeneratorService, 'generate')
.mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
}),
);
jest.spyOn(context.toolProviderService, 'getTools').mockResolvedValue(
createMockTools('testObject', {
canRead: true,
canCreate: true,
canUpdate: true,
canDelete: true,
}),
);
// Act
const tools = await context.agentToolService.generateToolsForAgent(
@@ -4,31 +4,16 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { type ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
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 { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
import { PerObjectToolGeneratorService } from 'src/engine/core-modules/tool-generator/services/per-object-tool-generator.service';
import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service';
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 { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-tool-generator.service';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
import { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
export interface AgentToolTestContext {
@@ -38,7 +23,7 @@ export interface AgentToolTestContext {
objectMetadataService: ObjectMetadataService;
roleRepository: Repository<RoleEntity>;
workspaceCacheService: WorkspaceCacheService;
perObjectToolGeneratorService: PerObjectToolGeneratorService;
toolProviderService: ToolProviderService;
twentyORMGlobalManager: TwentyORMGlobalManager;
testAgent: AgentEntity & { roleId: string | null };
testRole: RoleEntity;
@@ -97,89 +82,11 @@ export const createAgentToolTestModule =
},
},
{
provide: PerObjectToolGeneratorService,
provide: ToolProviderService,
useValue: {
generate: jest.fn().mockResolvedValue({}),
getTools: jest.fn().mockResolvedValue({}),
},
},
{
provide: ToolService,
useClass: ToolService,
},
{
provide: CreateRecordService,
useValue: {
execute: jest.fn(),
},
},
{
provide: UpdateRecordService,
useValue: {
execute: jest.fn(),
},
},
{
provide: DeleteRecordService,
useValue: {
execute: jest.fn(),
},
},
{
provide: FindRecordsService,
useValue: {
execute: jest.fn().mockResolvedValue({
success: true,
message: 'Records found successfully',
result: [],
}),
},
},
{
provide: RecordInputTransformerService,
useValue: {
process: jest.fn(async ({ recordInput }) => recordInput),
},
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn(),
},
},
{
provide: ToolAdapterService,
useClass: ToolAdapterService,
},
{
provide: ToolRegistryService,
useClass: ToolRegistryService,
},
{
provide: SendEmailTool,
useValue: {
description: 'mock',
inputSchema: {},
execute: jest.fn(),
},
},
{
provide: SearchArticlesTool,
useValue: {
description: 'Search for articles and documentation',
inputSchema: {},
execute: jest.fn(),
},
},
{
provide: ScopedWorkspaceContextFactory,
useValue: {
create: jest.fn(() => ({ workspaceId: 'test-workspace-id' })),
},
},
{
provide: MessagingSendMessageService,
useValue: { sendMessage: jest.fn() },
},
{
provide: PermissionsService,
useValue: {
@@ -188,18 +95,6 @@ export const createAgentToolTestModule =
checkRolesPermissions: jest.fn().mockResolvedValue(true),
},
},
{
provide: WorkflowToolWorkspaceService,
useValue: {
generateWorkflowTools: jest.fn().mockResolvedValue({}),
},
},
{
provide: TwentyConfigService,
useValue: {
get: jest.fn(),
},
},
],
}).compile();
@@ -286,44 +181,12 @@ export const createAgentToolTestModule =
fieldPermissions: [],
});
// Ensure ToolService input transformation has access to minimal metadata maps
const workspaceManyOrAllFlatEntityMapsCacheService =
module.get<WorkspaceManyOrAllFlatEntityMapsCacheService>(
WorkspaceManyOrAllFlatEntityMapsCacheService,
);
// Return a barebones flat object metadata map where fields are unknown (so transformer is a no-op)
const getMapsMock =
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps as jest.Mock;
getMapsMock.mockResolvedValue({
flatObjectMetadataMaps: {
byId: {
[testObjectMetadata.id]: {
...testObjectMetadata,
fieldMetadataIds: [],
indexMetadataIds: [],
viewIds: [],
universalIdentifier: testObjectMetadata.id,
applicationId: null,
} as any,
},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
},
flatFieldMetadataMaps: {
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
},
} as any);
const workspaceCacheService = module.get<WorkspaceCacheService>(
WorkspaceCacheService,
);
const perObjectToolGeneratorService =
module.get<PerObjectToolGeneratorService>(PerObjectToolGeneratorService);
const toolProviderService =
module.get<ToolProviderService>(ToolProviderService);
return {
module,
@@ -332,7 +195,7 @@ export const createAgentToolTestModule =
objectMetadataService,
roleRepository,
workspaceCacheService,
perObjectToolGeneratorService,
toolProviderService,
twentyORMGlobalManager,
testAgent,
testRole,