refactor(tool-provider): dedupe descriptor/generator paths (#19960)

## Summary

- Every tool provider used to implement `generateDescriptors()` **and**
register a category generator at `onModuleInit()` that re-ran the same
factories at execute time. `ToolExecutorService` carried two registries
(`staticToolHandlers`, `categoryGenerators`) to route between them.
- Providers now own execution of their own tools via a new
`executeStaticTool()` method. `ToolExecutorService` drops both maps and
delegates by `descriptor.category`. Each factory-backed provider has a
single `buildToolSet()` used by both descriptor generation and
execution.
- Extracts `resolveObjectIcon` shared util (was duplicated verbatim in
workflow + dashboard providers), and deletes the orphaned
`ToolGeneratorModule` whose consumers were removed in the earlier AI
chat simplification refactor.

No behavior change. Same factories run, same permission checks, same
tools execute. Net diff: 18 files, +311 / −480.

## Key changes

- `ToolProvider` interface gains `executeStaticTool(name, args,
context)`.
- `ToolExecutorService` loses its `staticToolHandlers` and
`categoryGenerators` maps, injects `TOOL_PROVIDERS`, and does
`providers.find(p => p.category ===
descriptor.category).executeStaticTool(...)` for `kind: 'static'`
descriptors.
- `ActionToolProvider` drops the register-handler loop in its
constructor; `executeStaticTool` looks up in the existing `toolMap`.
- `View`, `Metadata`, `Workflow`, `Dashboard`, `ViewField` providers
each have a single `buildToolSet(context)` private method used by both
`generateDescriptors` and `executeStaticTool`. No more `onModuleInit`,
no `ToolExecutorService` dependency.
- `DatabaseToolProvider` and `LogicFunctionToolProvider` implement
`executeStaticTool` with an invariant-violation throw — they only emit
`database_crud` / `logic_function` kinds, so the static-tool path is
unreachable for them.
- Deletes `tool-generator/` (dead code — zero consumers).

## Dependency graph before/after

**Before:** provider → `ToolExecutorService` (for `register*` calls)
**After:** `ToolExecutorService` → `TOOL_PROVIDERS` → providers.
Cleaner, no cycle.

## Test plan

- [ ] `npx nx typecheck twenty-server` passes (verified: same 7
pre-existing unrelated errors)
- [ ] `npx nx lint twenty-server` passes
- [ ] AI chat: trigger a tool call that hits `execute_tool` fallback
(e.g. a view/metadata tool not in the preloaded set) — verify it still
executes
- [ ] AI chat: trigger a preloaded action tool (e.g.
`search_help_center`) — verify it still executes
- [ ] MCP: `tools/list` and `tools/call` for both preloaded and
catalog-discovered tools
- [ ] Workflow AI agent: run a workflow with AI agent step that calls
DATABASE_CRUD tools
- [ ] Verify the `web_search` / `code_interpreter` tools (if enabled)
still dispatch correctly

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-04-22 12:32:03 +02:00
committed by GitHub
parent 3d164640c8
commit b77c44fd20
18 changed files with 320 additions and 478 deletions
@@ -1,134 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import {
type ObjectWithPermission,
type ToolFactory,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
@Injectable()
export class PerObjectToolGeneratorService {
private readonly logger = new Logger(PerObjectToolGeneratorService.name);
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
// Generate tools by iterating over workspace objects once and applying all factories
async generate(
context: ToolGeneratorContext,
factories: ToolFactory[],
): Promise<ToolSet> {
const objects = await this.getObjectsWithPermissions(
context.workspaceId,
context.rolePermissionConfig,
);
const tools: ToolSet = {};
for (const objectWithPermission of objects) {
for (const factory of factories) {
Object.assign(tools, factory(objectWithPermission, context));
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools from ${factories.length} factories for ${objects.length} objects`,
);
return tools;
}
// Get workspace objects with their permissions
async getObjectsWithPermissions(
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
): Promise<ObjectWithPermission[]> {
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
let objectPermissions;
if ('unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
objectPermissions = rolesPermissions[rolePermissionConfig.unionOf[0]];
} else {
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
} else if ('intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) => rolesPermissions[roleId],
);
objectPermissions =
allRolePermissions.length === 1
? allRolePermissions[0]
: computePermissionIntersection(allRolePermissions);
} else {
return [];
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allFlatObjects = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const allObjectMetadata = allFlatObjects.map((flatObject) => ({
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
}));
// Filter out workflow-related objects
const filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
);
// Map to ObjectWithPermission
const result: ObjectWithPermission[] = [];
for (const objectMetadata of filteredObjectMetadata) {
const permission = objectPermissions[objectMetadata.id];
if (!permission) {
continue;
}
result.push({
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
});
}
return result;
}
}
@@ -1,13 +0,0 @@
import { Module } from '@nestjs/common';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { PerObjectToolGeneratorService } from './services/per-object-tool-generator.service';
@Module({
imports: [WorkspaceCacheModule, WorkspaceManyOrAllFlatEntityMapsCacheModule],
providers: [PerObjectToolGeneratorService],
exports: [PerObjectToolGeneratorService],
})
export class ToolGeneratorModule {}
@@ -1,33 +0,0 @@
import { type ToolSet } from 'ai';
import {
type ActorMetadata,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
// Object metadata enriched with permission information
export type ObjectWithPermission = {
objectMetadata: ObjectMetadataForToolSchema;
restrictedFields: RestrictedFieldsPermissions;
canCreate: boolean;
canRead: boolean;
canUpdate: boolean;
canDelete: boolean;
};
// Context passed to tool factories
export type ToolGeneratorContext = {
workspaceId: string;
authContext?: WorkspaceAuthContext;
rolePermissionConfig: RolePermissionConfig;
actorContext?: ActorMetadata;
};
// Factory function type for generating tools from an object
export type ToolFactory = (
objectWithPermission: ObjectWithPermission,
context: ToolGeneratorContext,
) => ToolSet;
@@ -1,7 +0,0 @@
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export interface StaticToolHandler {
execute(args: ToolInput, context: ToolProviderContext): Promise<ToolOutput>;
}
@@ -4,6 +4,7 @@ import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-pro
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
export interface ToolProvider {
readonly category: ToolCategory;
@@ -14,4 +15,16 @@ export interface ToolProvider {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]>;
// Execute a tool whose descriptor has `executionRef.kind === 'static'` and
// `descriptor.category === this.category`. Providers own the execution of
// the tools they emit.
//
// Providers that never emit 'static' descriptors (database CRUD, logic
// functions) should throw — the call is unreachable by construction.
executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput>;
}
@@ -8,8 +8,6 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { type StaticToolHandler } from 'src/engine/core-modules/tool-provider/interfaces/static-tool-handler.interface';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
@@ -20,7 +18,7 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WebSearchTool } from 'src/engine/core-modules/tool/tools/web-search-tool/web-search-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { WebSearchService } from 'src/engine/core-modules/web-search/web-search.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@@ -42,7 +40,6 @@ export class ActionToolProvider implements ToolProvider {
private readonly codeInterpreterService: CodeInterpreterService,
private readonly webSearchService: WebSearchService,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
@@ -53,21 +50,6 @@ export class ActionToolProvider implements ToolProvider {
['navigate_app', this.navigateAppTool],
['web_search', this.webSearchTool],
]);
// Register each action tool as a static handler in the executor
for (const [toolId, tool] of this.toolMap) {
const handler: StaticToolHandler = {
execute: async (args: ToolInput, context: ToolProviderContext) =>
tool.execute(args, {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
}),
};
this.toolExecutorService.registerStaticHandler(toolId, handler);
}
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
@@ -155,6 +137,27 @@ export class ActionToolProvider implements ToolProvider {
return descriptors;
}
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const tool = this.toolMap.get(toolName);
if (!tool) {
throw new Error(
`Unknown action tool "${toolName}" (category: ${this.category})`,
);
}
return tool.execute(args, {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
});
}
private buildDescriptor(
toolId: string,
tool: Tool,
@@ -1,5 +1,6 @@
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -9,16 +10,18 @@ import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'twenty-shared/ai';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { resolveObjectIcon } from 'src/engine/core-modules/tool-provider/utils/resolve-object-icon.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service';
@Injectable()
export class DashboardToolProvider implements ToolProvider, OnModuleInit {
export class DashboardToolProvider implements ToolProvider {
readonly category = ToolCategory.DASHBOARD;
constructor(
@@ -26,25 +29,9 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
@Inject(DASHBOARD_TOOL_SERVICE_TOKEN)
private readonly dashboardToolService: DashboardToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
onModuleInit(): void {
if (this.dashboardToolService) {
const service = this.dashboardToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.DASHBOARD,
async (context) =>
service.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.dashboardToolService) {
return false;
@@ -61,16 +48,14 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.dashboardToolService) {
const toolSet = await this.buildToolSet(context);
if (!toolSet) {
return [];
}
const toolSet = await this.dashboardToolService.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
);
const icon = await this.resolveObjectIcon(
const icon = await resolveObjectIcon(
this.flatEntityMapsCacheService,
context.workspaceId,
CoreObjectNameSingular.Dashboard,
);
@@ -81,22 +66,37 @@ export class DashboardToolProvider implements ToolProvider, OnModuleInit {
});
}
private async resolveObjectIcon(
workspaceId: string,
nameSingular: string,
): Promise<string | undefined> {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = await this.buildToolSet(context);
if (!toolSet) {
throw new Error(
`Dashboard tool service is not available (tool: ${toolName})`,
);
}
const flatObject = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
).find((obj) => obj?.nameSingular === nameSingular);
return executeToolFromToolSet(
toolSet,
toolName,
args,
ToolCategory.DASHBOARD,
);
}
return flatObject?.icon ?? undefined;
private async buildToolSet(
context: ToolProviderContext,
): Promise<ToolSet | null> {
if (!this.dashboardToolService) {
return null;
}
return this.dashboardToolService.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
);
}
}
@@ -26,6 +26,7 @@ import {
import { ToolCategory } from 'twenty-shared/ai';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
@@ -44,6 +45,20 @@ export class DatabaseToolProvider implements ToolProvider {
return true;
}
// Database CRUD tools emit `executionRef.kind === 'database_crud'` descriptors
// and are dispatched inline by ToolExecutorService. The static-tool path is
// unreachable for this provider; this method exists only to satisfy the
// interface.
async executeStaticTool(
toolName: string,
_args: Record<string, unknown>,
_context: ToolProviderContext,
): Promise<ToolOutput> {
throw new Error(
`DatabaseToolProvider does not emit static-kind descriptors (tool: ${toolName})`,
);
}
async generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
@@ -10,6 +10,7 @@ import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/
import { ToolCategory } from 'twenty-shared/ai';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
@@ -25,6 +26,20 @@ export class LogicFunctionToolProvider implements ToolProvider {
return true;
}
// Logic function tools emit `executionRef.kind === 'logic_function'`
// descriptors and are dispatched inline by ToolExecutorService. The
// static-tool path is unreachable for this provider; this method exists
// only to satisfy the interface.
async executeStaticTool(
toolName: string,
_args: Record<string, unknown>,
_context: ToolProviderContext,
): Promise<ToolOutput> {
throw new Error(
`LogicFunctionToolProvider does not emit static-kind descriptors (tool: ${toolName})`,
);
}
async generateDescriptors(
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
@@ -1,5 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -7,38 +8,25 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
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 { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class MetadataToolProvider implements ToolProvider, OnModuleInit {
export class MetadataToolProvider implements ToolProvider {
readonly category = ToolCategory.METADATA;
constructor(
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const objectFactory = this.objectMetadataToolsFactory;
const fieldFactory = this.fieldMetadataToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.METADATA,
async (context) => ({
...objectFactory.generateTools(context.workspaceId),
...fieldFactory.generateTools(context.workspaceId),
}),
);
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
@@ -51,14 +39,33 @@ export class MetadataToolProvider implements ToolProvider, OnModuleInit {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const toolSet = {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
const toolSet = this.buildToolSet(context);
return toolSetToDescriptors(toolSet, ToolCategory.METADATA, {
includeSchemas: options?.includeSchemas ?? true,
icon: 'IconSettings',
});
}
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = this.buildToolSet(context);
return executeToolFromToolSet(
toolSet,
toolName,
args,
ToolCategory.METADATA,
);
}
private buildToolSet(context: ToolProviderContext): ToolSet {
return {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
}
}
@@ -1,5 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -7,49 +8,23 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFieldToolsFactory } from 'src/engine/metadata-modules/view-field/tools/view-field-tools.factory';
@Injectable()
export class ViewFieldToolProvider implements ToolProvider, OnModuleInit {
export class ViewFieldToolProvider implements ToolProvider {
readonly category = ToolCategory.VIEW_FIELD;
constructor(
private readonly viewFieldToolsFactory: ViewFieldToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewFieldToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW_FIELD,
async (context) => {
const readTools = factory.generateReadTools(context.workspaceId);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(context.workspaceId);
return { ...readTools, ...writeTools };
}
return readTools;
},
);
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
@@ -58,11 +33,30 @@ export class ViewFieldToolProvider implements ToolProvider, OnModuleInit {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const schemaOptions = {
const toolSet = await this.buildToolSet(context);
return toolSetToDescriptors(toolSet, ToolCategory.VIEW_FIELD, {
includeSchemas: options?.includeSchemas ?? true,
icon: 'IconTable',
};
});
}
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = await this.buildToolSet(context);
return executeToolFromToolSet(
toolSet,
toolName,
args,
ToolCategory.VIEW_FIELD,
);
}
private async buildToolSet(context: ToolProviderContext): Promise<ToolSet> {
const readTools = this.viewFieldToolsFactory.generateReadTools(
context.workspaceId,
);
@@ -74,22 +68,14 @@ export class ViewFieldToolProvider implements ToolProvider, OnModuleInit {
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = this.viewFieldToolsFactory.generateWriteTools(
context.workspaceId,
);
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW_FIELD,
schemaOptions,
);
if (!hasViewPermission) {
return readTools;
}
return toolSetToDescriptors(
readTools,
ToolCategory.VIEW_FIELD,
schemaOptions,
const writeTools = this.viewFieldToolsFactory.generateWriteTools(
context.workspaceId,
);
return { ...readTools, ...writeTools };
}
}
@@ -1,5 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -7,17 +8,18 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewFilterToolsFactory } from 'src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory';
import { ViewSortToolsFactory } from 'src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
export class ViewToolProvider implements ToolProvider, OnModuleInit {
export class ViewToolProvider implements ToolProvider {
readonly category = ToolCategory.VIEW;
constructor(
@@ -25,54 +27,8 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
private readonly viewFilterToolsFactory: ViewFilterToolsFactory,
private readonly viewSortToolsFactory: ViewSortToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const viewFactory = this.viewToolsFactory;
const filterFactory = this.viewFilterToolsFactory;
const sortFactory = this.viewSortToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW,
async (context) => {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = {
...viewFactory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
),
...filterFactory.generateReadTools(context.workspaceId),
...sortFactory.generateReadTools(context.workspaceId),
};
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = {
...viewFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...filterFactory.generateWriteTools(context.workspaceId),
...sortFactory.generateWriteTools(context.workspaceId),
};
return { ...readTools, ...writeTools };
}
return readTools;
},
);
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
@@ -81,10 +37,25 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const schemaOptions = {
const toolSet = await this.buildToolSet(context);
return toolSetToDescriptors(toolSet, ToolCategory.VIEW, {
includeSchemas: options?.includeSchemas ?? true,
};
});
}
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = await this.buildToolSet(context);
return executeToolFromToolSet(toolSet, toolName, args, ToolCategory.VIEW);
}
private async buildToolSet(context: ToolProviderContext): Promise<ToolSet> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = {
...this.viewToolsFactory.generateReadTools(
@@ -103,23 +74,19 @@ export class ViewToolProvider implements ToolProvider, OnModuleInit {
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
};
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW,
schemaOptions,
);
if (!hasViewPermission) {
return readTools;
}
return toolSetToDescriptors(readTools, ToolCategory.VIEW, schemaOptions);
const writeTools = {
...this.viewToolsFactory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
),
...this.viewFilterToolsFactory.generateWriteTools(context.workspaceId),
...this.viewSortToolsFactory.generateWriteTools(context.workspaceId),
};
return { ...readTools, ...writeTools };
}
}
@@ -1,5 +1,6 @@
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { Inject, Injectable, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
@@ -9,16 +10,18 @@ import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'twenty-shared/ai';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { executeToolFromToolSet } from 'src/engine/core-modules/tool-provider/utils/execute-tool-from-tool-set.util';
import { resolveObjectIcon } from 'src/engine/core-modules/tool-provider/utils/resolve-object-icon.util';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@Injectable()
export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
export class WorkflowToolProvider implements ToolProvider {
readonly category = ToolCategory.WORKFLOW;
constructor(
@@ -26,25 +29,9 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
onModuleInit(): void {
if (this.workflowToolService) {
const service = this.workflowToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.WORKFLOW,
async (context) =>
service.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.workflowToolService) {
return false;
@@ -61,16 +48,14 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
context: ToolProviderContext,
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
if (!this.workflowToolService) {
const toolSet = await this.buildToolSet(context);
if (!toolSet) {
return [];
}
const toolSet = await this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
const icon = await this.resolveObjectIcon(
const icon = await resolveObjectIcon(
this.flatEntityMapsCacheService,
context.workspaceId,
CoreObjectNameSingular.Workflow,
);
@@ -81,22 +66,37 @@ export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
});
}
private async resolveObjectIcon(
workspaceId: string,
nameSingular: string,
): Promise<string | undefined> {
const { flatObjectMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
async executeStaticTool(
toolName: string,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<ToolOutput> {
const toolSet = await this.buildToolSet(context);
if (!toolSet) {
throw new Error(
`Workflow tool service is not available (tool: ${toolName})`,
);
}
const flatObject = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
).find((obj) => obj?.nameSingular === nameSingular);
return executeToolFromToolSet(
toolSet,
toolName,
args,
ToolCategory.WORKFLOW,
);
}
return flatObject?.icon ?? undefined;
private async buildToolSet(
context: ToolProviderContext,
): Promise<ToolSet | null> {
if (!this.workflowToolService) {
return null;
}
return this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
}
}
@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type AggregateOperations } from 'twenty-shared/types';
@@ -26,9 +26,8 @@ import { GroupByRecordsService } from 'src/engine/core-modules/record-crud/servi
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type';
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'twenty-shared/ai';
import { type StaticToolHandler } from 'src/engine/core-modules/tool-provider/interfaces/static-tool-handler.interface';
import { type CategoryToolGenerator } from 'src/engine/core-modules/tool-provider/types/category-tool-generator.type';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolExecutionRef } from 'src/engine/core-modules/tool-provider/types/tool-execution-ref.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
@@ -40,16 +39,9 @@ import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/works
export class ToolExecutorService {
private readonly logger = new Logger(ToolExecutorService.name);
// Per-tool handlers (action tools, etc.)
private readonly staticToolHandlers = new Map<string, StaticToolHandler>();
// Category-level ToolSet generators (workflow, view, dashboard, metadata)
private readonly categoryGenerators = new Map<
ToolCategory,
CategoryToolGenerator
>();
constructor(
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
private readonly findRecordsService: FindRecordsService,
private readonly groupByRecordsService: GroupByRecordsService,
private readonly createRecordService: CreateRecordService,
@@ -63,17 +55,6 @@ export class ToolExecutorService {
private readonly userRepository: Repository<UserEntity>,
) {}
registerStaticHandler(toolId: string, handler: StaticToolHandler): void {
this.staticToolHandlers.set(toolId, handler);
}
registerCategoryGenerator(
category: ToolCategory,
generator: CategoryToolGenerator,
): void {
this.categoryGenerators.set(category, generator);
}
async dispatch(
descriptor: ToolIndexEntry | ToolDescriptor,
args: Record<string, unknown> | undefined,
@@ -222,35 +203,32 @@ export class ToolExecutorService {
throw new Error('Expected static executionRef');
}
// Per-tool handler first (action tools)
const handler = this.staticToolHandlers.get(descriptor.executionRef.toolId);
const provider = this.providers.find(
(candidate) => candidate.category === descriptor.category,
);
if (handler) {
return handler.execute(args, context);
}
// Category-level generator fallback (workflow, view, dashboard, metadata)
const generator = this.categoryGenerators.get(descriptor.category);
if (!generator) {
if (!provider) {
throw new Error(
`No handler or generator for static tool: ${descriptor.executionRef.toolId}`,
`No provider registered for category "${descriptor.category}" (tool: ${descriptor.executionRef.toolId})`,
);
}
const toolSet = await generator(context);
const tool = toolSet[descriptor.name];
if (!tool?.execute) {
throw new Error(
`Tool ${descriptor.name} not found in generated ToolSet for category ${descriptor.category}`,
);
// Defense-in-depth: catalog and by-name lookups already filter by
// `isAvailable`, but re-verify at dispatch so the gate is enforced in
// one place regardless of how the descriptor reached us.
if (!(await provider.isAvailable(context))) {
return {
success: false,
message: `Tool "${descriptor.name}" is not available`,
error: `Tool "${descriptor.name}" is not available in this context. Use get_tool_catalog to see available tools.`,
};
}
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
) as Promise<ToolOutput>;
return provider.executeStaticTool(
descriptor.executionRef.toolId,
args,
context,
);
}
private async dispatchLogicFunction(
@@ -2,7 +2,6 @@ import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { ActionToolProvider } from 'src/engine/core-modules/tool-provider/providers/action-tool.provider';
import { DashboardToolProvider } from 'src/engine/core-modules/tool-provider/providers/dashboard-tool.provider';
@@ -41,7 +40,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
@Module({
imports: [
ToolModule,
ToolGeneratorModule,
RecordCrudModule,
AiModelsModule,
forwardRef(() => AiAgentExecutionModule),
@@ -1,7 +0,0 @@
import { type ToolSet } from 'ai';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
export type CategoryToolGenerator = (
context: ToolProviderContext,
) => Promise<ToolSet>;
@@ -0,0 +1,32 @@
import { type ToolSet } from 'ai';
import { type ToolCategory } from 'twenty-shared/ai';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
// Invokes a tool from a factory-generated ToolSet by name. Used by providers
// whose tools are produced as opaque AI-SDK ToolSet closures (view, metadata,
// workflow, dashboard, view-field) and which therefore cannot dispatch by
// executionRef alone.
//
// The factory closures expect a `loadingMessage` field (added by the chat UX
// wrapper) and a ToolExecutionOptions object; neither is meaningful when the
// executor is invoking them internally, so we pass empty defaults.
export const executeToolFromToolSet = async (
toolSet: ToolSet,
toolName: string,
args: Record<string, unknown>,
category: ToolCategory,
): Promise<ToolOutput> => {
const tool = toolSet[toolName];
if (!tool?.execute) {
throw new Error(
`Tool "${toolName}" not found in ToolSet for category "${category}"`,
);
}
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
) as Promise<ToolOutput>;
};
@@ -0,0 +1,22 @@
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
// Looks up a core object's icon by its nameSingular. Shared by providers that
// tag descriptor batches with the icon of the underlying object (workflows,
// dashboards) so the UI can render per-tool icons.
export const resolveObjectIcon = async (
flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
workspaceId: string,
nameSingular: string,
): Promise<string | undefined> => {
const { flatObjectMetadataMaps } =
await flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps({
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
});
const flatObject = Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
).find((obj) => obj?.nameSingular === nameSingular);
return flatObject?.icon ?? undefined;
};