fix(ai) - optimize crud tools (#21133)

- **Add delete many**, `delete_many_{object}` added alongside the
existing `delete_one_{object}`.
- **Uniformize naming**, crud module, type names, and MCP helper
constants renamed for consistency.
- **Optimize tool schema (learn phase)**
  - `find_many(_companies)`: **7 158 → 2 700 tokens**
  - `find_one(_company)`: **280 → 126 tokens**
  -  ....
- Main mechanism: `reused: 'ref'` (line 7 of
`to-tool-json-schema.util.ts`). Zod walks the schema tree, tracks which
Zod schema instances appear more than once, and emits each reused
instance exactly once in `$defs`, replacing all subsequent occurrences
with a `$ref`. Works because filter and value schemas are now extracted
as shared objects.

- **Optimize system prompt (tool catalog)**, DATABASE_CRUD section
restructured to list operation patterns (`find_many_{object}`, …) once +
objects once, instead of the full N×M cross-product of tool names.
- **Optimize execute_tool**, shared record-properties schema (same
`$defs` deduplication applies at call time); introduced `upsert_many`;
added `selectedFields` to `find_*` so the agent only fetches the fields
it needs.
This commit is contained in:
Etienne
2026-06-03 19:57:40 +02:00
committed by GitHub
parent 50c9b68e81
commit 15eaabdbc1
40 changed files with 1610 additions and 633 deletions
@@ -109,15 +109,15 @@ describe('DatabaseToolProvider', () => {
expect(descriptorNames).toEqual(
expect.arrayContaining([
'create_note_target',
'create_one_note_target',
'create_many_note_targets',
'update_note_target',
'update_one_note_target',
'update_many_note_targets',
'delete_note_target',
'create_task_target',
'create_attachment',
'create_timeline_activity',
'create_person',
'delete_one_note_target',
'create_one_task_target',
'create_one_attachment',
'create_one_timeline_activity',
'create_one_person',
]),
);
});
@@ -147,27 +147,27 @@ describe('DatabaseToolProvider', () => {
expect(descriptorNames).toEqual(
expect.arrayContaining([
'find_workspace_members',
'find_messages',
'find_calendar_events',
'find_dashboards',
'find_many_workspace_members',
'find_many_messages',
'find_many_calendar_events',
'find_many_dashboards',
]),
);
expect(descriptorNames).toEqual(
expect.not.arrayContaining([
'create_workspace_member',
'update_workspace_member',
'delete_workspace_member',
'create_message',
'update_message',
'delete_message',
'create_calendar_event',
'update_calendar_event',
'delete_calendar_event',
'create_dashboard',
'update_dashboard',
'delete_dashboard',
'create_one_workspace_member',
'update_one_workspace_member',
'delete_one_workspace_member',
'create_one_message',
'update_one_message',
'delete_one_message',
'create_one_calendar_event',
'update_one_calendar_event',
'delete_one_calendar_event',
'create_one_dashboard',
'update_one_dashboard',
'delete_one_dashboard',
]),
);
});
@@ -4,9 +4,8 @@ import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { camelToSnakeCase } from 'twenty-shared/utils';
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { z } from 'zod';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
@@ -17,6 +16,8 @@ import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/rec
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { generateBulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema';
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';
@@ -32,6 +33,7 @@ import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadat
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { ToolCategory } from 'twenty-shared/ai';
import z from 'zod';
@Injectable()
export class DatabaseToolProvider implements ToolProvider {
@@ -129,10 +131,10 @@ export class DatabaseToolProvider implements ToolProvider {
if (permission.canReadObjectRecords) {
descriptors.push({
name: `find_${snakePlural}`,
name: `find_many_${snakePlural}`,
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_${snakePlural}`) && {
...(shouldIncludeSchema(`find_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
@@ -140,16 +142,16 @@ export class DatabaseToolProvider implements ToolProvider {
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'find',
operation: 'find_many',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'find',
operation: 'find_many',
});
descriptors.push({
name: `find_one_${snakeSingular}`,
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.`,
description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
@@ -180,7 +182,7 @@ export class DatabaseToolProvider implements ToolProvider {
category: ToolCategory.DATABASE_CRUD,
...(shouldGenerateGroupBy &&
groupBySchema && {
inputSchema: z.toJSONSchema(groupBySchema),
inputSchema: toToolJsonSchema(groupBySchema),
}),
executionRef: {
kind: 'database_crud',
@@ -196,10 +198,10 @@ export class DatabaseToolProvider implements ToolProvider {
if (permission.canUpdateObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `create_${snakeSingular}`,
name: `create_one_${snakeSingular}`,
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.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_${snakeSingular}`) && {
...(shouldIncludeSchema(`create_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
@@ -207,11 +209,11 @@ export class DatabaseToolProvider implements ToolProvider {
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'create',
operation: 'create_one',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'create',
operation: 'create_one',
});
descriptors.push({
@@ -237,10 +239,10 @@ export class DatabaseToolProvider implements ToolProvider {
});
descriptors.push({
name: `update_${snakeSingular}`,
name: `update_one_${snakeSingular}`,
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.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_${snakeSingular}`) && {
...(shouldIncludeSchema(`update_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
@@ -248,16 +250,16 @@ export class DatabaseToolProvider implements ToolProvider {
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'update',
operation: 'update_one',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'update',
operation: 'update_one',
});
descriptors.push({
name: `update_many_${snakePlural}`,
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
description: `Apply the SAME field values to all ${objectMetadata.labelPlural} records matching a filter. Use when every matched record gets identical changes (e.g. bulk status change). For records that each have different data to update, use upsert_many_${snakePlural} instead. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`update_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
@@ -276,24 +278,68 @@ export class DatabaseToolProvider implements ToolProvider {
icon: flatObject.icon ?? undefined,
operation: 'update_many',
});
}
if (permission.canSoftDeleteObjectRecords && canBeManagedByAutomation) {
descriptors.push({
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
name: `upsert_many_${snakePlural}`,
description: `Insert or update multiple ${objectMetadata.labelPlural} records in a single call, where each record has its own individual data. Use this instead of update_many_${snakePlural} when records need different field values. Existing records are matched by unique fields and updated; records with no match are created. Maximum 20 records per call. Returns the upserted records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`delete_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
...(shouldIncludeSchema(`upsert_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'delete',
operation: 'upsert_many',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'delete',
operation: 'upsert_many',
});
}
if (permission.canSoftDeleteObjectRecords) {
descriptors.push({
name: `delete_one_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas && {
inputSchema: toToolJsonSchema(DeleteToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'delete_one',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'delete_one',
});
descriptors.push({
name: `delete_many_${snakePlural}`,
description: `Soft-delete multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. Deleted records are hidden from normal queries and the operation is reversible. WARNING: Use specific filters to avoid unintended mass deletions.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas && {
inputSchema: toToolJsonSchema(
generateBulkDeleteToolInputSchema(
objectMetadata,
restrictedFields,
),
),
}),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'delete_many',
},
objectName: objectMetadata.nameSingular,
icon: flatObject.icon ?? undefined,
operation: 'delete_many',
});
}
}
@@ -307,14 +353,16 @@ export class DatabaseToolProvider implements ToolProvider {
snakePlural: string,
): boolean {
return (
toolNames.has(`find_${snakePlural}`) ||
toolNames.has(`find_many_${snakePlural}`) ||
toolNames.has(`find_one_${snakeSingular}`) ||
toolNames.has(`group_by_${snakePlural}`) ||
toolNames.has(`create_${snakeSingular}`) ||
toolNames.has(`create_one_${snakeSingular}`) ||
toolNames.has(`create_many_${snakePlural}`) ||
toolNames.has(`update_${snakeSingular}`) ||
toolNames.has(`update_one_${snakeSingular}`) ||
toolNames.has(`update_many_${snakePlural}`) ||
toolNames.has(`delete_${snakeSingular}`)
toolNames.has(`delete_one_${snakeSingular}`) ||
toolNames.has(`delete_many_${snakePlural}`) ||
toolNames.has(`upsert_many_${snakePlural}`)
);
}
@@ -20,11 +20,13 @@ import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-u
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteManyRecordsService } from 'src/engine/core-modules/record-crud/services/delete-many-records.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 { 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 { UpsertManyRecordsService } from 'src/engine/core-modules/record-crud/services/upsert-many-records.service';
import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.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';
@@ -48,7 +50,9 @@ export class ToolExecutorService {
private readonly createManyRecordsService: CreateManyRecordsService,
private readonly updateRecordService: UpdateRecordService,
private readonly updateManyRecordsService: UpdateManyRecordsService,
private readonly upsertManyRecordsService: UpsertManyRecordsService,
private readonly deleteRecordService: DeleteRecordService,
private readonly deleteManyRecordsService: DeleteManyRecordsService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectRepository(UserEntity)
@@ -89,8 +93,8 @@ export class ToolExecutorService {
context.authContext ?? (await this.buildAuthContext(context));
switch (ref.operation) {
case 'find': {
const { limit, offset, orderBy, ...filter } = args;
case 'find_many': {
const { limit, offset, orderBy, select, ...filter } = args;
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
@@ -98,21 +102,28 @@ export class ToolExecutorService {
orderBy: orderBy as FindRecordsParams['orderBy'],
limit: limit as number | undefined,
offset: offset as number | undefined,
select: select as string[],
shouldBuildEffectiveSelectFields: true,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
}
case 'find_one':
case 'find_one': {
const { select, id } = args;
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter: { id: { eq: args.id } },
filter: { id: { eq: id } },
limit: 1,
select: select as string[],
shouldBuildEffectiveSelectFields: isDefined(select),
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
}
case 'create':
case 'create_one':
return this.createRecordService.execute({
objectName: ref.objectNameSingular,
objectRecord: args,
@@ -132,7 +143,7 @@ export class ToolExecutorService {
slimResponse: true,
});
case 'update': {
case 'update_one': {
const { id, ...fields } = args;
const objectRecord = Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
@@ -158,7 +169,17 @@ export class ToolExecutorService {
slimResponse: true,
});
case 'delete':
case 'upsert_many':
return this.upsertManyRecordsService.execute({
objectName: ref.objectNameSingular,
objectRecords: args.records as Record<string, unknown>[],
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
slimResponse: true,
});
case 'delete_one':
return this.deleteRecordService.execute({
objectName: ref.objectNameSingular,
objectRecordId: args.id as string,
@@ -167,6 +188,14 @@ export class ToolExecutorService {
soft: true,
});
case 'delete_many':
return this.deleteManyRecordsService.execute({
objectName: ref.objectNameSingular,
filter: args.filter as Record<string, unknown>,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
case 'group_by': {
const {
groupBy,
@@ -60,7 +60,7 @@ export const createExecuteToolTool = (
return {
success: false,
message: `Tool "${toolName}" is not available`,
error: `Tool "${toolName}" is not available in this context. Use learn_tools to discover available tools.`,
error: `Tool "${toolName}" is not available in this context. Use get_tool_catalog to discover available tools.`,
};
}
@@ -1,9 +1,11 @@
export type DatabaseCrudOperation =
| 'find'
| 'find_many'
| 'find_one'
| 'create'
| 'create_one'
| 'create_many'
| 'update'
| 'update_one'
| 'update_many'
| 'delete'
| 'upsert_many'
| 'delete_one'
| 'delete_many'
| 'group_by';