[AI] Add group_by_* database tools and centralize groupBy validation (#19406)

closes
https://discord.com/channels/1130383047699738754/1488990242873806868



https://github.com/user-attachments/assets/2b2bbfba-3fa6-4114-9a26-96a61599d748

<img width="729" height="1283" alt="CleanShot 2026-04-07 at 20 43 06"
src="https://github.com/user-attachments/assets/815efb97-81a0-44ea-8d79-b3ce7d5b00b6"
/>


<img width="708" height="1266" alt="CleanShot 2026-04-07 at 20 40 13"
src="https://github.com/user-attachments/assets/692366bc-b629-4d9f-b6b8-ab670d5ad046"
/>

<img width="665" height="3524" alt="CleanShot 2026-04-07 at 20 42 00"
src="https://github.com/user-attachments/assets/5e844e0f-7835-47a8-9d20-a5baddc0992d"
/>
This commit is contained in:
nitin
2026-04-10 14:15:18 +05:30
committed by GitHub
parent 43ce396152
commit f13e7e01fe
32 changed files with 1504 additions and 258 deletions
@@ -19,6 +19,10 @@ import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-
import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/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 {
generateGroupByToolInputSchema,
hasGroupByToolInputSchema,
} from 'src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema';
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';
@@ -138,6 +142,33 @@ export class DatabaseToolProvider implements ToolProvider {
icon: flatObject.icon ?? undefined,
operation: 'find_one',
});
const groupBySchema = includeSchemas
? generateGroupByToolInputSchema(objectMetadata, restrictedFields)
: null;
const hasGroupBySchema =
groupBySchema !== null ||
hasGroupByToolInputSchema(objectMetadata, restrictedFields);
if (hasGroupBySchema) {
descriptors.push({
name: `group_by_${snakePlural}`,
description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas &&
groupBySchema && {
inputSchema: z.toJSONSchema(groupBySchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'group_by',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'group_by',
});
}
}
if (permission.canUpdateObjectRecords) {
@@ -1,9 +1,12 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type AggregateOperations } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type FlatWorkspace } from 'src/engine/core-modules/workspace/types/flat-workspace.type';
import { fromUserEntityToFlat } from 'src/engine/core-modules/user/utils/from-user-entity-to-flat.util';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
@@ -19,6 +22,7 @@ import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/se
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 { GroupByRecordsService } from 'src/engine/core-modules/record-crud/services/group-by-records.service';
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';
@@ -48,6 +52,7 @@ export class ToolExecutorService {
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly groupByRecordsService: GroupByRecordsService,
private readonly createRecordService: CreateRecordService,
private readonly createManyRecordsService: CreateManyRecordsService,
private readonly updateRecordService: UpdateRecordService,
@@ -181,6 +186,31 @@ export class ToolExecutorService {
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
case 'group_by': {
const {
groupBy,
aggregateOperation,
aggregateFieldName,
limit: groupByLimit,
orderBy: groupByOrderBy,
...groupByFilter
} = args;
return this.groupByRecordsService.execute({
objectName: ref.objectNameSingular,
groupBy: groupBy as ObjectRecordGroupBy,
aggregateOperation: aggregateOperation as
| keyof typeof AggregateOperations
| undefined,
aggregateFieldName: aggregateFieldName as string | undefined,
limit: groupByLimit as number | undefined,
orderBy: groupByOrderBy as 'ASC' | 'DESC' | undefined,
filter: groupByFilter,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
}
}
}
@@ -5,4 +5,5 @@ export type DatabaseCrudOperation =
| 'create_many'
| 'update'
| 'update_many'
| 'delete';
| 'delete'
| 'group_by';