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:
+140
@@ -0,0 +1,140 @@
|
||||
import { type ToolSet } from 'ai';
|
||||
|
||||
import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
|
||||
import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
|
||||
import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
|
||||
import { type 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 { 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 {
|
||||
type ObjectWithPermission,
|
||||
type ToolGeneratorContext,
|
||||
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
|
||||
|
||||
// Dependencies required by the direct record tools factory
|
||||
export type DirectRecordToolsDeps = {
|
||||
createRecordService: CreateRecordService;
|
||||
updateRecordService: UpdateRecordService;
|
||||
deleteRecordService: DeleteRecordService;
|
||||
findRecordsService: FindRecordsService;
|
||||
};
|
||||
|
||||
export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
|
||||
return (
|
||||
{
|
||||
objectMetadata,
|
||||
restrictedFields,
|
||||
canCreate,
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
}: ObjectWithPermission,
|
||||
context: ToolGeneratorContext,
|
||||
): ToolSet => {
|
||||
const tools: ToolSet = {};
|
||||
|
||||
if (canRead) {
|
||||
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 deps.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
workspaceId: context.workspaceId,
|
||||
rolePermissionConfig: context.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 deps.findRecordsService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
filter: { id: { eq: parameters.input.id } },
|
||||
limit: 1,
|
||||
workspaceId: context.workspaceId,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (canCreate) {
|
||||
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 deps.createRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecord: parameters.input,
|
||||
workspaceId: context.workspaceId,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
createdBy: context.actorContext,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (canUpdate) {
|
||||
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 deps.updateRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: id,
|
||||
objectRecord,
|
||||
workspaceId: context.workspaceId,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (canDelete) {
|
||||
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 deps.deleteRecordService.execute({
|
||||
objectName: objectMetadata.nameSingular,
|
||||
objectRecordId: parameters.input.id,
|
||||
workspaceId: context.workspaceId,
|
||||
rolePermissionConfig: context.rolePermissionConfig,
|
||||
soft: true,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return tools;
|
||||
};
|
||||
};
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
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 {
|
||||
type ToolHints,
|
||||
type 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 { 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[],
|
||||
toolHints?: ToolHints,
|
||||
): Promise<ToolSet> {
|
||||
const objects = await this.getFilteredObjectsWithPermissions(
|
||||
context.workspaceId,
|
||||
context.rolePermissionConfig,
|
||||
toolHints,
|
||||
);
|
||||
|
||||
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, filtered by toolHints
|
||||
async getFilteredObjectsWithPermissions(
|
||||
workspaceId: string,
|
||||
rolePermissionConfig: RolePermissionConfig,
|
||||
toolHints?: ToolHints,
|
||||
): 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.byId)
|
||||
.filter(isDefined)
|
||||
.filter((obj) => obj.isActive && !obj.isSystem);
|
||||
|
||||
const allObjectMetadata = allFlatObjects.map((flatObject) => ({
|
||||
...flatObject,
|
||||
fields: getFlatFieldsFromFlatObjectMetadata(
|
||||
flatObject,
|
||||
flatFieldMetadataMaps,
|
||||
),
|
||||
}));
|
||||
|
||||
// Filter out workflow-related objects
|
||||
let filteredObjectMetadata = allObjectMetadata.filter(
|
||||
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
|
||||
);
|
||||
|
||||
// Apply toolHints filtering if provided
|
||||
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(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Map to ObjectWithPermission
|
||||
const result: ObjectWithPermission[] = [];
|
||||
|
||||
const operationsSet = toolHints?.operations
|
||||
? new Set(toolHints.operations)
|
||||
: null;
|
||||
|
||||
const shouldIncludeOperation = (operation: ToolOperation) =>
|
||||
!operationsSet || operationsSet.has(operation);
|
||||
|
||||
for (const objectMetadata of filteredObjectMetadata) {
|
||||
const permission = objectPermissions[objectMetadata.id];
|
||||
|
||||
if (!permission) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push({
|
||||
objectMetadata,
|
||||
restrictedFields: permission.restrictedFields,
|
||||
canCreate:
|
||||
shouldIncludeOperation('create') && permission.canUpdateObjectRecords,
|
||||
canRead:
|
||||
shouldIncludeOperation('find') && permission.canReadObjectRecords,
|
||||
canUpdate:
|
||||
shouldIncludeOperation('update') && permission.canUpdateObjectRecords,
|
||||
canDelete:
|
||||
shouldIncludeOperation('delete') &&
|
||||
permission.canSoftDeleteObjectRecords,
|
||||
});
|
||||
}
|
||||
|
||||
if (operationsSet) {
|
||||
this.logger.log(
|
||||
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
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 {}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type ToolSet } from 'ai';
|
||||
import {
|
||||
type ActorMetadata,
|
||||
type RestrictedFieldsPermissions,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
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;
|
||||
rolePermissionConfig: RolePermissionConfig;
|
||||
actorContext?: ActorMetadata;
|
||||
};
|
||||
|
||||
// Factory function type for generating tools from an object
|
||||
export type ToolFactory = (
|
||||
objectWithPermission: ObjectWithPermission,
|
||||
context: ToolGeneratorContext,
|
||||
) => ToolSet;
|
||||
Reference in New Issue
Block a user