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:
@@ -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],
|
||||
|
||||
+12
-15
@@ -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,
|
||||
|
||||
+7
@@ -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',
|
||||
);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export enum ToolCategory {
|
||||
DATABASE_CRUD = 'DATABASE_CRUD',
|
||||
ACTION = 'ACTION',
|
||||
WORKFLOW = 'WORKFLOW',
|
||||
METADATA = 'METADATA',
|
||||
NATIVE_MODEL = 'NATIVE_MODEL',
|
||||
}
|
||||
+352
@@ -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 {}
|
||||
+16
@@ -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 {}
|
||||
|
||||
+3
-4
@@ -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,
|
||||
|
||||
+3
-6
@@ -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,
|
||||
|
||||
+25
-45
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+17
-143
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+15
-91
@@ -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 {}
|
||||
-172
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
-138
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
-54
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
-58
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
-1
@@ -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`,
|
||||
|
||||
+16
-26
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+30
-125
@@ -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(
|
||||
|
||||
+7
-144
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user