refactor(workflow-tools): reorganize to one file per tool with co-located schemas (#16313)

## Summary

Reorganizes workflow tools to improve maintainability and
discoverability by having one file per tool with co-located input
schemas.

## Changes

- Create individual tool files in `tools/` directory (11 files)
- Co-locate input schemas with their tool implementations
- Add shared types file for dependencies and context
- Simplify workspace service to aggregate tool factories
- Remove centralized `schemas/` directory

## New Structure

```
workflow-tools/
├── services/
│   └── workflow-tool.workspace-service.ts
├── tools/
│   ├── activate-workflow-version.tool.ts
│   ├── compute-step-output-schema.tool.ts
│   ├── create-complete-workflow.tool.ts
│   ├── create-draft-from-workflow-version.tool.ts
│   ├── create-workflow-version-edge.tool.ts
│   ├── create-workflow-version-step.tool.ts
│   ├── deactivate-workflow-version.tool.ts
│   ├── delete-workflow-version-edge.tool.ts
│   ├── delete-workflow-version-step.tool.ts
│   ├── update-workflow-version-positions.tool.ts
│   └── update-workflow-version-step.tool.ts
├── types/
│   └── workflow-tool-dependencies.type.ts
└── workflow-tools.module.ts
```

## Benefits

- **Co-location**: Schema and tool logic are in the same file
- **Single responsibility**: Each file handles one tool
- **Easier maintenance**: Changes to a tool only touch one file
- **Better discoverability**: File names match tool names
This commit is contained in:
Félix Malfait
2025-12-04 21:06:49 +01:00
committed by GitHub
parent 1991ee850e
commit 9cecbaebc3
46 changed files with 2323 additions and 1186 deletions
@@ -1,31 +1,62 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.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';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AgentMessagePartEntity } from './entities/agent-message-part.entity';
import { AgentMessageEntity } from './entities/agent-message.entity';
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';
@Module({
imports: [
AiBillingModule,
AiModelsModule,
AiToolsModule,
AiAgentModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
UserRoleModule,
PermissionsModule,
WorkspaceCacheModule,
TypeOrmModule.forFeature([
AgentEntity,
AgentMessageEntity,
AgentMessagePartEntity,
AgentTurnEntity,
RoleTargetEntity,
]),
],
providers: [AgentAsyncExecutorService],
providers: [
AgentAsyncExecutorService,
AgentExecutionService,
AgentToolGeneratorService,
AgentModelConfigService,
AgentActorContextService,
AgentPlanExecutorService,
],
exports: [
AgentAsyncExecutorService,
AgentExecutionService,
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentActorContextService,
AgentModelConfigService,
TypeOrmModule.forFeature([
AgentMessageEntity,
AgentMessagePartEntity,
@@ -11,6 +11,7 @@ import {
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
AgentExceptionCode,
@@ -18,7 +19,7 @@ import {
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service';
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';
@@ -26,18 +27,43 @@ import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/to
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';
// Agent execution within workflows uses database and action tools only.
// Workflow tools are intentionally excluded to avoid circular dependencies
// and recursive workflow execution.
@Injectable()
export class AgentAsyncExecutorService {
private readonly logger = new Logger(AgentAsyncExecutorService.name);
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
private readonly toolService: ToolService,
) {}
private async getTools(
private extractRoleIds(
rolePermissionConfig?: RolePermissionConfig,
): string[] {
if (!rolePermissionConfig) {
return [];
}
if ('intersectionOf' in rolePermissionConfig) {
return rolePermissionConfig.intersectionOf;
}
if ('unionOf' in rolePermissionConfig) {
return rolePermissionConfig.unionOf;
}
return [];
}
private async getToolsForWorkflowExecution(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
@@ -45,43 +71,42 @@ export class AgentAsyncExecutorService {
): Promise<ToolSet> {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId: agentId,
agentId,
workspaceId,
},
select: ['roleId'],
});
const agentRoleId = roleTarget?.roleId;
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
if (!rolePermissionConfig && !agentRoleId) {
return await this.toolAdapterService.getTools();
// 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();
}
let effectiveRoleContext: RolePermissionConfig;
if (
rolePermissionConfig &&
('intersectionOf' in rolePermissionConfig ||
'unionOf' in rolePermissionConfig)
) {
effectiveRoleContext = rolePermissionConfig;
} else if (agentRoleId) {
effectiveRoleContext = { unionOf: [agentRoleId] };
} else {
return await this.toolAdapterService.getTools();
}
const actionTools = await this.toolAdapterService.getTools(
effectiveRoleContext,
workspaceId,
);
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,
@@ -103,14 +128,35 @@ export class AgentAsyncExecutorService {
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const tools = agent
? await this.getTools(
agent.id,
agent.workspaceId,
actorContext,
rolePermissionConfig,
)
: {};
let tools: ToolSet = {};
let providerOptions = {};
if (agent) {
tools = await this.getToolsForWorkflowExecution(
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 };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
agent as unknown as Parameters<
typeof this.agentModelConfigService.getProviderOptions
>[1],
);
}
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
@@ -120,7 +166,22 @@ export class AgentAsyncExecutorService {
model: registeredModel.model,
prompt: userPrompt,
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
providerOptions,
experimental_telemetry: AI_TELEMETRY_CONFIG,
experimental_repairToolCall: async ({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
}) => {
return repairToolCall({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
model: registeredModel.model,
});
},
});
const agentSchema =
@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import {
convertToModelMessages,
LanguageModelUsage,
stepCountIs,
streamText,
ToolSet,
@@ -24,9 +23,6 @@ import {
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-actor-context.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-model-config.service';
import { AgentToolGeneratorService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-tool-generator.service';
import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
@@ -37,10 +33,12 @@ import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/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';
export interface AgentExecutionResult {
result: object;
usage: LanguageModelUsage;
}
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
export { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
export interface StreamChatResponseResult {
stream: ReturnType<typeof streamText>;
@@ -63,8 +61,8 @@ export class AgentExecutionService {
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
public readonly agentActorContextService: AgentActorContextService,
public readonly agentService: AgentService,
private readonly agentActorContextService: AgentActorContextService,
private readonly agentService: AgentService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
@@ -75,6 +73,7 @@ export class AgentExecutionService {
actorContext,
roleIds,
toolHints,
additionalTools,
}: {
system: string;
agent: FlatAgentWithRoleId | null;
@@ -82,6 +81,7 @@ export class AgentExecutionService {
actorContext?: ActorMetadata;
roleIds?: string[];
toolHints?: ToolHints;
additionalTools?: ToolSet;
}) {
try {
if (agent) {
@@ -112,7 +112,11 @@ export class AgentExecutionService {
agent,
);
tools = { ...baseTools, ...nativeModelTools };
tools = {
...baseTools,
...nativeModelTools,
...(additionalTools || {}),
};
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -120,7 +124,9 @@ export class AgentExecutionService {
);
}
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
this.logger.log(
`Generated ${Object.keys(tools).length} tools for agent (including ${Object.keys(additionalTools || {}).length} additional tools)`,
);
return {
system,
@@ -278,6 +284,7 @@ export class AgentExecutionService {
messages,
recordIdsByObjectMetadataNameSingular,
toolHints,
additionalTools,
}: {
workspace: WorkspaceEntity;
userWorkspaceId: string;
@@ -285,6 +292,7 @@ export class AgentExecutionService {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
toolHints?: ToolHints;
additionalTools?: ToolSet;
}): Promise<{
stream: ReturnType<typeof streamText>;
timings: {
@@ -345,6 +353,7 @@ export class AgentExecutionService {
actorContext,
roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])],
toolHints,
additionalTools,
});
const aiRequestPrepTime = Date.now() - aiRequestPrepStart;
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type PlanStep } from 'src/engine/metadata-modules/ai/ai-chat-router/types/router-result.interface';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
@@ -32,7 +33,10 @@ export type PlanExecutionResult = {
export class AgentPlanExecutorService {
private readonly logger = new Logger(AgentPlanExecutorService.name);
constructor(private readonly agentExecutionService: AgentExecutionService) {}
constructor(
private readonly agentExecutionService: AgentExecutionService,
private readonly agentService: AgentService,
) {}
async executePlan({
steps,
@@ -70,11 +74,10 @@ export class AgentPlanExecutorService {
`[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`,
);
const agent =
await this.agentExecutionService.agentService.findOneAgentByName({
name: step.agentName,
workspaceId: workspace.id,
});
const agent = await this.agentService.findOneAgentByName({
name: step.agentName,
workspaceId: workspace.id,
});
this.logger.log(
`[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`,
@@ -11,10 +11,7 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag
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 { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@Injectable()
export class AgentToolGeneratorService {
@@ -25,8 +22,6 @@ export class AgentToolGeneratorService {
private readonly agentRepository: Repository<AgentEntity>,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
private readonly workflowToolService: WorkflowToolService,
private readonly permissionsService: PermissionsService,
private readonly searchArticlesTool: SearchArticlesTool,
) {}
@@ -56,21 +51,8 @@ export class AgentToolGeneratorService {
return this.wrapToolsWithErrorContext(tools);
}
const hasWorkflowPermission =
await this.permissionsService.checkRolesPermissions(
{ intersectionOf: roleIds },
workspaceId,
PermissionFlagType.WORKFLOWS,
);
if (hasWorkflowPermission) {
const workflowTools = this.workflowToolService.generateWorkflowTools(
workspaceId,
{ intersectionOf: roleIds },
);
tools = { ...tools, ...workflowTools };
}
// 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 },
@@ -0,0 +1,6 @@
import { type LanguageModelUsage } from 'ai';
export interface AgentExecutionResult {
result: object;
usage: LanguageModelUsage;
}
@@ -3,48 +3,32 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
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 { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai/ai-agent-role/ai-agent-role.module';
import { AgentGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/ai/ai-agent/interceptors/agent-graphql-api-exception.interceptor';
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 { 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 { FlatAgentModule } from 'src/engine/metadata-modules/flat-agent/flat-agent.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
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';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration-v2/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
import { AgentEntity } from './entities/agent.entity';
import { AgentActorContextService } from './services/agent-actor-context.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 { AgentTitleGenerationService } from './services/agent-title-generation.service';
import { AgentToolGeneratorService } from './services/agent-tool-generator.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetEntity]),
AiModelsModule,
AiToolsModule,
AiBillingModule,
AiAgentRoleModule,
ThrottlerModule,
AuditModule,
@@ -53,15 +37,7 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
FileModule,
ObjectMetadataModule,
PermissionsModule,
AiChatRouterModule,
WorkspaceCacheStorageModule,
TokenModule,
WorkspaceDomainsModule,
WorkflowToolsModule,
UserWorkspaceModule,
UserRoleModule,
WorkspaceCacheModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationV2Module,
ApplicationModule,
FlatAgentModule,
@@ -70,24 +46,9 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
providers: [
AgentResolver,
AgentService,
AgentExecutionService,
AgentModelConfigService,
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentTitleGenerationService,
AgentActorContextService,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
AgentGraphqlApiExceptionInterceptor,
],
exports: [
AgentService,
AgentExecutionService,
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentTitleGenerationService,
AgentActorContextService,
AgentModelConfigService,
TypeOrmModule.forFeature([AgentEntity]),
],
exports: [AgentService, TypeOrmModule.forFeature([AgentEntity])],
})
export class AiAgentModule {}
@@ -15,6 +15,7 @@ import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-bi
import { AiChatRouterModule } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatController } from './controllers/agent-chat.controller';
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
@@ -22,6 +23,8 @@ import { AgentChatResolver } from './resolvers/agent-chat.resolver';
import { AgentChatRoutingService } from './services/agent-chat-routing.service';
import { AgentChatStreamingService } from './services/agent-chat-streaming.service';
import { AgentChatService } from './services/agent-chat.service';
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
import { ChatToolsProviderService } from './services/chat-tools-provider.service';
@Module({
imports: [
@@ -42,6 +45,9 @@ import { AgentChatService } from './services/agent-chat.service';
TokenModule,
UserWorkspaceModule,
AiBillingModule,
// Provides WorkflowToolWorkspaceService for ChatToolsProviderService
// Workflow tools are only available in chat context, not in workflow executor (to avoid circular deps)
WorkflowToolsModule,
],
controllers: [AgentChatController],
providers: [
@@ -49,6 +55,8 @@ import { AgentChatService } from './services/agent-chat.service';
AgentChatService,
AgentChatStreamingService,
AgentChatRoutingService,
AgentTitleGenerationService,
ChatToolsProviderService,
],
exports: [
AgentChatService,
@@ -6,14 +6,17 @@ import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentMessageRole } from 'src/engine/metadata-modules/ai/ai-agent-execution/entities/agent-message.entity';
import { AgentExecutionService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service';
import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-plan-executor.service';
import { AgentActorContextService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-actor-context.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-execution.service';
import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai/ai-agent-execution/services/agent-plan-executor.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai/ai-billing/utils/convert-cents-to-billing-credits.util';
import { AiChatRouterService } from 'src/engine/metadata-modules/ai/ai-chat-router/ai-chat-router.service';
import { type ModelId } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { ChatToolsProviderService } from './chat-tools-provider.service';
export type TokenUsage = {
promptTokens: number;
completionTokens: number;
@@ -53,6 +56,8 @@ export class AgentChatRoutingService {
private readonly agentPlanExecutorService: AgentPlanExecutorService,
private readonly aiChatRouterService: AiChatRouterService,
private readonly aiBillingService: AIBillingService,
private readonly chatToolsProviderService: ChatToolsProviderService,
private readonly agentActorContextService: AgentActorContextService,
) {}
async streamAgentExecution({
@@ -234,6 +239,23 @@ export class AgentChatRoutingService {
const agentExecutionStart = Date.now();
// Get workflow tools for chat context (these are NOT available in workflow executor)
// Use user's role for determining workflow tool permissions
const { roleId } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
);
const roleIds = [roleId];
const workflowTools =
await this.chatToolsProviderService.getWorkflowToolsForChat(
workspace.id,
roleIds,
toolHints,
);
const {
stream: result,
timings,
@@ -245,6 +267,7 @@ export class AgentChatRoutingService {
messages,
recordIdsByObjectMetadataNameSingular,
toolHints,
additionalTools: workflowTools,
});
const routedStatusPart = {
@@ -17,9 +17,10 @@ import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentTitleGenerationService } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-title-generation.service';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai/ai-chat/entities/agent-chat-thread.entity';
import { AgentTitleGenerationService } from './agent-title-generation.service';
@Injectable()
export class AgentChatService {
constructor(
@@ -0,0 +1,64 @@
/* eslint-disable @nx/workspace-inject-workspace-repository */
import { Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
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,
) {}
// Provides workflow-specific tools for the chat context
// These tools are NOT available in the workflow executor context to prevent circular dependencies
async getWorkflowToolsForChat(
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,
);
const allWorkflowTools = { ...workflowTools, ...recordStepTools };
this.logger.log(
`Generated ${Object.keys(allWorkflowTools).length} workflow tools for chat context`,
);
return allWorkflowTools;
}
}
@@ -6,6 +6,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
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';
@@ -27,6 +28,7 @@ import { MessagingModule } from 'src/modules/messaging/messaging.module';
TokenModule,
FeatureFlagModule,
RecordCrudModule,
ToolGeneratorModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceCacheStorageModule,
UserRoleModule,
@@ -1,48 +1,44 @@
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 { 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 { 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';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { getMockObjectMetadataEntity } from 'src/utils/__test__/get-object-metadata-entity.mock';
// Minimal mock repository type
const createMockRepository = () => ({
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
delete: jest.fn(),
remove: jest.fn(),
});
describe('ToolService', () => {
const workspaceId = 'ws_1';
const roleId = 'role_1';
let service: ToolService;
let workspaceCacheService: WorkspaceCacheService;
let perObjectToolGenerator: PerObjectToolGeneratorService;
const testObject = getMockObjectMetadataEntity({
workspaceId: '',
id: 'obj_1',
nameSingular: 'testObject',
namePlural: 'testObjects',
labelSingular: 'Test Object',
labelPlural: 'Test Objects',
isActive: true,
isSystem: false,
fields: [],
});
const mockRepo = createMockRepository();
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();
@@ -51,68 +47,9 @@ describe('ToolService', () => {
providers: [
ToolService,
{
provide: TwentyORMGlobalManager,
provide: PerObjectToolGeneratorService,
useValue: {
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepo),
},
},
{
provide: WorkspaceManyOrAllFlatEntityMapsCacheService,
useValue: {
getOrRecomputeManyOrAllFlatEntityMaps: jest.fn().mockResolvedValue({
flatObjectMetadataMaps: {
byId: {
[testObject.id]: {
...testObject,
fieldMetadataIds: [],
},
},
},
flatFieldMetadataMaps: {
byId: {},
},
}),
},
},
{
provide: WorkspaceCacheService,
useValue: {
getOrRecompute: jest.fn().mockResolvedValue({
rolesPermissions: {
[roleId]: {
[testObject.id]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
},
},
},
}),
},
},
{
provide: RecordInputTransformerService,
useValue: {
process: jest.fn(async ({ recordInput }) => recordInput),
},
},
{
provide: WorkspaceCacheStorageService,
useValue: {
getObjectMetadataMapsOrThrow: jest.fn().mockResolvedValue({
byId: {
[testObject.id]: {
...testObject,
fieldsById: {},
fieldIdByJoinColumnName: {},
fieldIdByName: {},
indexMetadatas: [],
},
},
idByNameSingular: { [testObject.nameSingular]: testObject.id },
}),
generate: jest.fn().mockResolvedValue(mockTools),
},
},
{
@@ -135,42 +72,66 @@ describe('ToolService', () => {
}).compile();
service = moduleRef.get(ToolService);
workspaceCacheService = moduleRef.get(WorkspaceCacheService);
perObjectToolGenerator = moduleRef.get(PerObjectToolGeneratorService);
});
describe('listTools', () => {
it('should return tools based on role permissions', async () => {
it('should call perObjectToolGenerator.generate with correct parameters', async () => {
const tools = await service.listTools({ unionOf: [roleId] }, workspaceId);
expect(workspaceCacheService.getOrRecompute).toHaveBeenCalledWith(
workspaceId,
['rolesPermissions'],
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
}),
expect.any(Array),
undefined,
);
// Verify tool keys
expect(tools['create_testObject']).toBeDefined();
expect(tools['update_testObject']).toBeDefined();
expect(tools['find_testObject']).toBeDefined();
expect(tools['soft_delete_testObject']).toBeDefined();
expect(tools['soft_delete_many_testObject']).toBeDefined();
// Ensure the execute functions are wired
expect(typeof tools['create_testObject'].execute).toBe('function');
expect(tools).toBe(mockTools);
});
});
describe('softDeleteManyRecords', () => {
it('should error when filter is invalid', async () => {
const result = await (service as any).softDeleteManyRecords(
'testObject',
{},
it('should pass toolHints to perObjectToolGenerator.generate', async () => {
const toolHints: ToolHints = {
relevantObjects: ['company', 'person'],
operations: ['create', 'find'],
};
await service.listTools(
{ unionOf: [roleId] },
workspaceId,
roleId,
undefined,
toolHints,
);
expect(result.success).toBe(false);
expect(result.error).toBe(
'Filter with record IDs is required for bulk soft delete',
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
}),
expect.any(Array),
toolHints,
);
});
it('should pass actorContext to perObjectToolGenerator.generate', async () => {
const actorContext = {
source: FieldActorSource.API,
workspaceMemberId: 'member_1',
name: 'Test User',
context: {},
};
await service.listTools({ unionOf: [roleId] }, workspaceId, actorContext);
expect(perObjectToolGenerator.generate).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId,
rolePermissionConfig: { unionOf: [roleId] },
actorContext,
}),
expect.any(Array),
undefined,
);
});
});
@@ -1,337 +1,58 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
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 { 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 { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import {
ToolHints,
ToolOperation,
} from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
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 logger = new Logger(ToolService.name);
private readonly directRecordToolsDeps: DirectRecordToolsDeps;
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
protected readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
) {}
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
// Supports filtering by object names and operation types via toolHints
// Returns a map of tool names to tool definitions
async listTools(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
actorContext?: ActorMetadata,
toolHints?: ToolHints,
): Promise<ToolSet> {
const tools: ToolSet = {};
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 {
// TODO: Implement union logic for multiple roles
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 tools;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const allFlatObjects = Object.values(flatObjectMetadataMaps.byId)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const allObjectMetadata = allFlatObjects.map((flatObject) => ({
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
flatObject,
flatFieldMetadataMaps,
),
}));
let filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
const directRecordToolsFactory = createDirectRecordToolsFactory(
this.directRecordToolsDeps,
);
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
const relevantSet = new Set(toolHints.relevantObjects);
const originalCount = filteredObjectMetadata.length;
filteredObjectMetadata = filteredObjectMetadata.filter(
(obj) =>
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
);
this.logger.log(
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
);
if (filteredObjectMetadata.length === 0) {
this.logger.warn(
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
);
}
}
const operationsSet = toolHints?.operations
? new Set(toolHints.operations)
: null;
const shouldIncludeOperation = (operation: ToolOperation) =>
!operationsSet || operationsSet.has(operation);
const shouldIncludeFind = shouldIncludeOperation('find');
const shouldIncludeCreate = shouldIncludeOperation('create');
const shouldIncludeUpdate = shouldIncludeOperation('update');
const shouldIncludeDelete = shouldIncludeOperation('delete');
filteredObjectMetadata.forEach((objectMetadata) => {
const objectPermission = objectPermissions[objectMetadata.id];
if (!objectPermission) {
return;
}
const restrictedFields = objectPermission.restrictedFields;
if (shouldIncludeFind && objectPermission.canReadObjectRecords) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { limit, offset, orderBy, ...filter } = parameters.input;
return this.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter,
orderBy,
limit,
offset,
workspaceId,
rolePermissionConfig,
});
},
};
tools[`find_one_${objectMetadata.nameSingular}`] = {
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
inputSchema: FindOneToolInputSchema,
execute: async (parameters) => {
return this.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.input.id } },
limit: 1,
workspaceId,
rolePermissionConfig,
});
},
};
}
if (objectPermission.canUpdateObjectRecords) {
if (shouldIncludeCreate) {
tools[`create_${objectMetadata.nameSingular}`] = {
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
inputSchema: generateCreateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
return this.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
workspaceId,
rolePermissionConfig,
createdBy: actorContext,
});
},
};
}
if (shouldIncludeUpdate) {
tools[`update_${objectMetadata.nameSingular}`] = {
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
inputSchema: generateUpdateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { id, ...allFields } = parameters.input;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return this.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
workspaceId,
rolePermissionConfig,
});
},
};
}
}
if (shouldIncludeDelete && objectPermission.canSoftDeleteObjectRecords) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
inputSchema: SoftDeleteToolInputSchema,
execute: async (parameters) => {
return this.deleteRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.input.id,
workspaceId,
rolePermissionConfig,
soft: true,
});
},
};
tools[`soft_delete_many_${objectMetadata.nameSingular}`] = {
description: `Soft delete multiple ${objectMetadata.labelSingular} records at once by providing an array of record IDs. All records are marked as deleted but remain in the database. This is efficient for bulk operations and preserves all data.`,
inputSchema: BulkDeleteToolInputSchema,
execute: async (parameters) => {
return this.softDeleteManyRecords(
objectMetadata.nameSingular,
parameters.input,
workspaceId,
rolePermissionConfig,
);
},
};
}
});
if (operationsSet) {
this.logger.log(
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
);
}
return tools;
}
private async softDeleteManyRecords(
objectName: string,
parameters: Record<string, unknown>,
workspaceId: string,
rolePermissionConfig: RolePermissionConfig,
) {
try {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectName,
rolePermissionConfig,
);
const { filter } = parameters;
if (!filter || typeof filter !== 'object' || !('id' in filter)) {
return {
success: false,
message: `Failed to soft delete many ${objectName}: Filter with record IDs is required`,
error: 'Filter with record IDs is required for bulk soft delete',
};
}
const idFilter = filter.id as Record<string, unknown>;
const recordIds = idFilter.in;
if (!Array.isArray(recordIds) || recordIds.length === 0) {
return {
success: false,
message: `Failed to soft delete many ${objectName}: At least one record ID is required`,
error: 'At least one record ID is required for bulk soft delete',
};
}
const existingRecords = await repository.find({
where: { id: { in: recordIds } },
});
if (existingRecords.length === 0) {
return {
success: false,
message: `Failed to soft delete many ${objectName}: No records found with the provided IDs`,
error: 'No records found to soft delete',
};
}
await repository.softDelete({ id: { in: recordIds } });
return {
success: true,
message: `Successfully soft deleted ${existingRecords.length} ${objectName} records`,
result: {
count: existingRecords.length,
deletedIds: recordIds,
},
};
} catch (error) {
return {
success: false,
message: `Failed to soft delete many ${objectName}`,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
return this.perObjectToolGenerator.generate(
{
workspaceId,
rolePermissionConfig,
actorContext,
},
[directRecordToolsFactory],
toolHints,
);
}
}