From 15eaabdbc1b542ebe937f4fbd9296b1be32d1a45 Mon Sep 17 00:00:00 2001 From: Etienne <45695613+etiennejouan@users.noreply.github.com> Date: Wed, 3 Jun 2026 19:57:40 +0200 Subject: [PATCH] fix(ai) - optimize crud tools (#21133) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **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. --- .../skills/call-transcript-summarization.ts | 2 +- .../__tests__/getToolDisplayMessage.test.ts | 12 +- .../validate-files-field-or-throw.util.ts | 2 +- .../__tests__/mcp-protocol.service.spec.ts | 2 +- .../build-mcp-server-instructions.util.ts | 8 +- .../record-crud/record-crud.module.ts | 6 + .../services/delete-many-records.service.ts | 87 ++++ .../services/find-records.service.ts | 31 +- .../services/upsert-many-records.service.ts | 105 ++++ .../types/delete-many-records-params.type.ts | 9 + .../types/find-records-params.type.ts | 2 + .../types/upsert-many-records-params.type.ts | 14 + .../build-selected-field.util.spec.ts | 274 +++++++++++ .../build-effective-selected-fields.util.ts | 258 ++++++++++ ...te-update-many-record-input-schema.util.ts | 5 +- .../utils/to-tool-json-schema.util.ts | 27 + .../bulk-delete-tool.zod-schema.ts | 36 +- .../zod-schemas/field-filters.zod-schema.ts | 465 ++---------------- .../zod-schemas/find-one-tool.zod-schema.ts | 8 + .../zod-schemas/find-tool.zod-schema.ts | 19 +- .../zod-schemas/group-by-tool.zod-schema.ts | 4 +- .../zod-schemas/order-by.zod-schema.ts | 17 +- .../zod-schemas/record-filter.zod-schema.ts | 23 +- .../record-properties.zod-schema.ts | 154 +++--- .../shared-filter-defs.zod-schema.ts | 243 +++++++++ .../shared-value-defs.zod-schema.ts | 62 +++ .../__tests__/database-tool.provider.spec.ts | 46 +- .../providers/database-tool.provider.ts | 106 ++-- .../services/tool-executor.service.ts | 43 +- .../tool-provider/tools/execute-tool.tool.ts | 2 +- .../types/database-crud-operation.type.ts | 10 +- .../twenty-mcp-helper.const.ts | 8 +- .../tool/types/tool-output.type.ts | 1 + .../constants/agent-system-prompts.const.ts | 3 +- .../constants/chat-system-prompts.const.ts | 9 +- .../services/system-prompt-builder.service.ts | 75 ++- ...clude-field-from-agent-tool-schema.util.ts | 4 +- ...reate-standard-flat-skill-metadata.util.ts | 19 +- .../find-records.workflow-action.ts | 1 + .../mcp-tool-execution.integration-spec.ts | 41 +- 40 files changed, 1610 insertions(+), 633 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/delete-many-records.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/delete-many-records-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-many-records-params.type.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/build-selected-field.util.spec.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/utils/to-tool-json-schema.util.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts create mode 100644 packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema.ts diff --git a/packages/twenty-apps/internal/call-recording/src/skills/call-transcript-summarization.ts b/packages/twenty-apps/internal/call-recording/src/skills/call-transcript-summarization.ts index 4ff22f0618..14412e5647 100644 --- a/packages/twenty-apps/internal/call-recording/src/skills/call-transcript-summarization.ts +++ b/packages/twenty-apps/internal/call-recording/src/skills/call-transcript-summarization.ts @@ -43,7 +43,7 @@ A brief note on the overall tone of the conversation (collaborative, tense, expl - If the transcript is very short (under 200 words), provide a brief 2-3 sentence summary instead of the full structure. ## Saving the Summary -After generating the summary, use \`update_callRecording\` to save it in the \`summary\` field with the format: +After generating the summary, use \`update_one_call_recording\` to save it in the \`summary\` field with the format: \`\`\`json { "summary": { "blocknote": null, "markdown": "" } } \`\`\` diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts index bd0c3b8948..32d149f77b 100644 --- a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts @@ -23,14 +23,14 @@ describe('resolveToolInput', () => { it('should unwrap execute_tool input', () => { const input = { - toolName: 'find_companies', + toolName: 'find_many_companies', arguments: { filter: { name: 'Acme' } }, }; const result = resolveToolInput(input, 'execute_tool'); expect(result).toEqual({ resolvedInput: { filter: { name: 'Acme' } }, - resolvedToolName: 'find_companies', + resolvedToolName: 'find_many_companies', }); }); @@ -108,13 +108,13 @@ describe('getToolDisplayMessage', () => { describe('learn_tools', () => { it('should show tool names when provided', () => { const message = getToolDisplayMessage( - { toolNames: ['find_companies', 'create_task'] }, + { toolNames: ['find_many_companies', 'create_one_task'] }, 'learn_tools', true, ); expect(message).toContain('Learned'); - expect(message).toContain('find_companies, create_task'); + expect(message).toContain('find_many_companies, create_one_task'); }); it('should show generic message without tool names', () => { @@ -182,13 +182,13 @@ describe('getToolDisplayMessage', () => { describe('execute_tool wrapper', () => { it('should unwrap execute_tool and display inner tool name', () => { const message = getToolDisplayMessage( - { toolName: 'find_companies', arguments: { limit: 10 } }, + { toolName: 'find_many_companies', arguments: { limit: 10 } }, 'execute_tool', true, ); expect(message).toContain('Ran'); - expect(message).toContain('find companies'); + expect(message).toContain('find many companies'); }); }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts index 8a8f29d6ab..0d93f2d77b 100644 --- a/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util.ts @@ -16,7 +16,7 @@ import { const fileItemSchema = z .object({ - fileId: z.string().uuidv4(), + fileId: z.uuidv4(), label: z.string(), }) .strict(); diff --git a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts index 801a5f9d31..bef04f4421 100644 --- a/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts +++ b/packages/twenty-server/src/engine/api/mcp/services/__tests__/mcp-protocol.service.spec.ts @@ -273,7 +273,7 @@ describe('McpProtocolService', () => { method: 'tools/call', params: { name: 'execute_tool', - arguments: { toolName: 'find_companies', arguments: {} }, + arguments: { toolName: 'find_many_companies', arguments: {} }, }, id: '123', }; diff --git a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts index 40c30075b0..7e3586072b 100644 --- a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts +++ b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts @@ -19,8 +19,8 @@ export const buildMcpServerInstructions = ( ``, ...(skillNames ? [`Available skills: ${skillNames}.`, ``] : []), `CRUD tool name grammar — construct names directly without prior discovery:`, - ` Read: find_{objects} | find_one_{object} | group_by_{objects}`, - ` Write: create_{object} | create_many_{objects} | update_{object} | update_many_{objects} | delete_{object}`, + ` Read: find_many_{objects} | find_one_{object} | group_by_{objects}`, + ` Write: create_one_{object} | create_many_{objects} | update_one_{object} | update_many_{objects} | delete_one_{object} | delete_many_{objects} | upsert_many_{objects}. Use upsert_many_{objects} instead of update_many_{objects} when each record has its own individual data.`, ``, `Non-CRUD tools — use learn_tools for schemas:`, ` ACTION: http_request | send_email | draft_email | navigate_app | code_interpreter | search_help_center`, @@ -39,8 +39,8 @@ export const buildMcpServerInstructions = ( ` ⚠️ Never call workflow, dashboard, or metadata tools without loading their skill first.`, ``, `Route by intent:`, - ` Named entity ("Acme company") → find_{objects} to resolve id first, then operate on id`, - ` Retrieve records → find_{objects} (default limit: 10, always report total count)`, + ` Named entity ("Acme company") → find_many_{objects} to resolve id first, then operate on id`, + ` Retrieve records → find_many_{objects} (default limit: 10, always report total count)`, ` Single record by id → find_one_{object}`, ` Analytics / grouped metrics → group_by_{objects} (COUNT, SUM, AVG, MIN, MAX)`, ` Multiple metrics → run parallel group_by calls, merge results`, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts index a824d7279e..0205c4d0e8 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/record-crud.module.ts @@ -5,11 +5,13 @@ import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module'; import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.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 { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module'; @@ -30,9 +32,11 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache UpdateRecordService, UpdateManyRecordsService, DeleteRecordService, + DeleteManyRecordsService, FindRecordsService, GroupByRecordsService, UpsertRecordService, + UpsertManyRecordsService, ], exports: [ CreateRecordService, @@ -40,9 +44,11 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache UpdateRecordService, UpdateManyRecordsService, DeleteRecordService, + DeleteManyRecordsService, FindRecordsService, GroupByRecordsService, UpsertRecordService, + UpsertManyRecordsService, ], }) export class RecordCrudModule {} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-many-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-many-records.service.ts new file mode 100644 index 0000000000..473db7fb07 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/delete-many-records.service.ts @@ -0,0 +1,87 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { CommonDeleteManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-delete-many-query-runner.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; +import { type DeleteManyRecordsParams } from 'src/engine/core-modules/record-crud/types/delete-many-records-params.type'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { isDefined, isEmptyObject } from 'twenty-shared/utils'; +import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow'; + +@Injectable() +export class DeleteManyRecordsService { + private readonly logger = new Logger(DeleteManyRecordsService.name); + + constructor( + private readonly commonDeleteManyRunner: CommonDeleteManyQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, + ) {} + + async execute(params: DeleteManyRecordsParams): Promise { + const { objectName, filter, authContext } = params; + + if (!isDefined(filter) || isEmptyObject(filter)) { + return { + success: false, + message: `Failed to delete records from ${objectName}`, + error: + 'Filter must not be empty — deleting without a filter is not allowed', + }; + } + + try { + const { queryRunnerContext, selectedFields, flatObjectMetadata } = + await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); + + if ( + !canObjectBeManagedByAutomation({ + nameSingular: flatObjectMetadata.nameSingular, + }) + ) { + throw new RecordCrudException( + 'Failed to delete: Object cannot be deleted by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const { results: deletedRecords } = + await this.commonDeleteManyRunner.execute( + { filter, selectedFields }, + queryRunnerContext, + ); + + this.logger.log( + `Soft deleted ${deletedRecords.length} records from ${objectName}`, + ); + + return { + success: true, + message: `Soft deleted ${deletedRecords.length} records from ${objectName}`, + result: deletedRecords.map((record) => ({ id: record.id })), + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to delete records from ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to delete records: ${error}`); + + return { + success: false, + message: `Failed to delete records from ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to delete records', + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts index c2dc283774..929f5f986e 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/find-records.service.ts @@ -5,12 +5,15 @@ import { OrderByDirection, type ObjectRecord } from 'twenty-shared/types'; import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { isNonEmptyArray } from '@sniptt/guards'; import { CommonFindManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-find-many-query-runner.service'; import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; import { type FindRecordsParams } from 'src/engine/core-modules/record-crud/types/find-records-params.type'; import { type FindRecordsResult } from 'src/engine/core-modules/record-crud/types/find-records-result.type'; +import { buildEffectiveSelectedFields } from 'src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util'; import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { isDefined } from 'twenty-shared/utils'; @Injectable() export class FindRecordsService { @@ -31,12 +34,22 @@ export class FindRecordsService { limit, offset = 0, authContext, + select, + shouldBuildEffectiveSelectFields, } = params; + if (shouldBuildEffectiveSelectFields && !isNonEmptyArray(select)) { + return { + success: false, + message: 'Select at least one field in select parameter', + error: 'Select is required', + }; + } + try { const { queryRunnerContext, - selectedFields, + selectedFields: allSelectableFields, flatObjectMetadata, flatFieldMetadataMaps, } = await this.commonApiContextBuilder.build({ @@ -44,6 +57,19 @@ export class FindRecordsService { objectName, }); + const { effectiveSelectedFields, warnings } = + shouldBuildEffectiveSelectFields && isDefined(select) + ? buildEffectiveSelectedFields({ + select, + filter, + orderBy, + objectName, + flatObjectMetadata, + flatFieldMetadataMaps, + selectedFields: allSelectableFields, + }) + : { effectiveSelectedFields: allSelectableFields, warnings: [] }; + // Add id to orderBy for consistent pagination const orderByWithIdCondition: ObjectRecordOrderBy = [ ...(orderBy ?? []).filter((item) => item !== undefined), @@ -58,7 +84,7 @@ export class FindRecordsService { orderBy: orderByWithIdCondition, first: limit ? Math.min(limit, QUERY_MAX_RECORDS) : QUERY_MAX_RECORDS, offset, - selectedFields: { ...selectedFields, totalCount: true }, + selectedFields: { ...effectiveSelectedFields, totalCount: true }, }, queryRunnerContext, ); @@ -82,6 +108,7 @@ export class FindRecordsService { records, count: totalCount, }, + ...(isNonEmptyArray(warnings) ? { warnings: warnings } : {}), recordReferences, }; } catch (error) { diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts new file mode 100644 index 0000000000..cb2126fe86 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/services/upsert-many-records.service.ts @@ -0,0 +1,105 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service'; +import { + RecordCrudException, + RecordCrudExceptionCode, +} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception'; +import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service'; +import { type UpsertManyRecordsParams } from 'src/engine/core-modules/record-crud/types/upsert-many-records-params.type'; +import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util'; +import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util'; +import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type'; +import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow'; + +@Injectable() +export class UpsertManyRecordsService { + private readonly logger = new Logger(UpsertManyRecordsService.name); + + constructor( + private readonly commonCreateManyRunner: CommonCreateManyQueryRunnerService, + private readonly commonApiContextBuilder: CommonApiContextBuilderService, + ) {} + + async execute(params: UpsertManyRecordsParams): Promise { + const { objectName, objectRecords, authContext } = params; + + try { + const { + queryRunnerContext, + selectedFields, + flatObjectMetadata, + flatFieldMetadataMaps, + } = await this.commonApiContextBuilder.build({ + authContext, + objectName, + }); + + if ( + !canObjectBeManagedByAutomation({ + nameSingular: flatObjectMetadata.nameSingular, + }) + ) { + throw new RecordCrudException( + 'Failed to upsert: Object cannot be upserted by workflow', + RecordCrudExceptionCode.INVALID_REQUEST, + ); + } + + const cleanedRecords = objectRecords.map((record) => ({ + ...removeUndefinedFromRecord(record), + ...(isDefined(params.createdBy) && { createdBy: params.createdBy }), + })); + + const { results: upsertedRecords } = + await this.commonCreateManyRunner.execute( + { + data: cleanedRecords, + selectedFields, + upsert: true, + }, + queryRunnerContext, + ); + + this.logger.log( + `Upserted ${upsertedRecords.length} records in ${objectName}`, + ); + + return { + success: true, + message: `Upserted ${upsertedRecords.length} records in ${objectName}`, + result: params.slimResponse + ? upsertedRecords.map((record) => ({ id: record.id })) + : upsertedRecords, + recordReferences: upsertedRecords.map((record) => ({ + objectNameSingular: objectName, + recordId: record.id, + displayName: getRecordDisplayName( + record, + flatObjectMetadata, + flatFieldMetadataMaps, + ), + })), + }; + } catch (error) { + if (error instanceof RecordCrudException) { + return { + success: false, + message: `Failed to upsert records in ${objectName}`, + error: error.message, + }; + } + + this.logger.error(`Failed to upsert records: ${error}`); + + return { + success: false, + message: `Failed to upsert records in ${objectName}`, + error: + error instanceof Error ? error.message : 'Failed to upsert records', + }; + } + } +} diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-many-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-many-records-params.type.ts new file mode 100644 index 0000000000..2276c72394 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/delete-many-records-params.type.ts @@ -0,0 +1,9 @@ +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type DeleteManyRecordsParams = { + objectName: string; + filter: Record; + authContext: WorkspaceAuthContext; + rolePermissionConfig?: RolePermissionConfig; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts index ff27d71ac4..00a0a9666d 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/find-records-params.type.ts @@ -15,4 +15,6 @@ export type FindRecordsParams = FindRecordsInput & | Partial[]; orderBy?: Partial; offset?: number; + select?: string[]; + shouldBuildEffectiveSelectFields: boolean; }; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-many-records-params.type.ts b/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-many-records-params.type.ts new file mode 100644 index 0000000000..13a768c4dd --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/types/upsert-many-records-params.type.ts @@ -0,0 +1,14 @@ +import { type ActorMetadata } from 'twenty-shared/types'; + +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type'; +import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config'; + +export type UpsertManyRecordsParams = { + objectName: string; + objectRecords: ObjectRecordProperties[]; + authContext: WorkspaceAuthContext; + rolePermissionConfig?: RolePermissionConfig; + createdBy?: ActorMetadata; + slimResponse?: boolean; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/build-selected-field.util.spec.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/build-selected-field.util.spec.ts new file mode 100644 index 0000000000..5b9920d8bf --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/__tests__/build-selected-field.util.spec.ts @@ -0,0 +1,274 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { buildEffectiveSelectedFields } from 'src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; + +const buildFlatFieldMetadataMaps = ( + fields: Array<{ id: string; name: string; type: FieldMetadataType }>, +): FlatEntityMaps => { + const universalIdentifierById: Partial> = {}; + const byUniversalIdentifier: Partial< + Record> + > = {}; + + for (const field of fields) { + const uid = `uid-${field.id}`; + + universalIdentifierById[field.id] = uid; + byUniversalIdentifier[uid] = { name: field.name, type: field.type }; + } + + return { + universalIdentifierById, + byUniversalIdentifier, + universalIdentifiersByApplicationId: {}, + } as unknown as FlatEntityMaps; +}; + +const buildFlatObjectMetadata = ( + labelIdentifierFieldMetadataId: string | undefined, + fieldIds: string[], +): FlatObjectMetadata => + ({ + labelIdentifierFieldMetadataId, + fieldIds, + }) as unknown as FlatObjectMetadata; + +const FIELD_IDS = { + name: 'field-id-name', + email: 'field-id-email', + searchVector: 'field-id-search-vector', + richText: 'field-id-richText', +}; + +const defaultFlatFieldMetadataMaps = buildFlatFieldMetadataMaps([ + { id: FIELD_IDS.name, name: 'name', type: FieldMetadataType.TEXT }, + { id: FIELD_IDS.email, name: 'emails', type: FieldMetadataType.EMAILS }, + { + id: FIELD_IDS.searchVector, + name: 'searchVector', + type: FieldMetadataType.TS_VECTOR, + }, + { + id: FIELD_IDS.richText, + name: 'richText', + type: FieldMetadataType.RICH_TEXT, + }, +]); + +const defaultFlatObjectMetadata = buildFlatObjectMetadata(FIELD_IDS.name, [ + FIELD_IDS.name, + FIELD_IDS.email, + FIELD_IDS.searchVector, + FIELD_IDS.richText, +]); + +const defaultSelectedFields: CommonSelectedFields = { + id: true, + name: true, + email: true, + searchVector: true, + body: { blocknote: true, markdown: true }, +}; + +describe('buildEffectiveSelectedFields', () => { + describe('when select is ["*"] (wildcard case)', () => { + it('should return all selectable fields excluding searchVector', () => { + const { effectiveSelectedFields, warnings } = + buildEffectiveSelectedFields({ + select: ['*'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toEqual([]); + expect(effectiveSelectedFields).toHaveProperty('id'); + expect(effectiveSelectedFields).toHaveProperty('name'); + expect(effectiveSelectedFields).toHaveProperty('email'); + expect(effectiveSelectedFields).not.toHaveProperty('searchVector'); + }); + }); + + describe('when select lists specific fields', () => { + it('should return only the requested fields plus id', () => { + const { effectiveSelectedFields, warnings } = + buildEffectiveSelectedFields({ + select: ['name'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toEqual([]); + expect(effectiveSelectedFields).toHaveProperty('id'); + expect(effectiveSelectedFields).toHaveProperty('name'); + expect(effectiveSelectedFields).not.toHaveProperty('email'); + expect(effectiveSelectedFields).not.toHaveProperty('searchVector'); + }); + + it('should always include id even if not listed in select', () => { + const { effectiveSelectedFields } = buildEffectiveSelectedFields({ + select: ['email'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(effectiveSelectedFields).toHaveProperty('id'); + }); + }); + + describe('warning case', () => { + it('should emit a warning with a suggestion for a near-miss field name', () => { + const { warnings } = buildEffectiveSelectedFields({ + select: ['nam'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Field 'nam' not found on person"); + expect(warnings[0]).toContain('name'); + }); + + it('should emit a warning without a suggestion for a completely unknown field', () => { + const { warnings } = buildEffectiveSelectedFields({ + select: ['zzz_totally_unknown'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain( + "Field 'zzz_totally_unknown' not found on person", + ); + expect(warnings[0]).not.toContain('Did you mean'); + }); + + it('should emit one warning per unknown field', () => { + const { warnings } = buildEffectiveSelectedFields({ + select: ['unknownA', 'unknownB'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toHaveLength(2); + }); + }); + + describe('searchVector field exclusion', () => { + it('should exclude searchVector from wildcard results', () => { + const { effectiveSelectedFields } = buildEffectiveSelectedFields({ + select: ['*'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(effectiveSelectedFields).not.toHaveProperty('searchVector'); + }); + + it('should emit a warning when searchVector is explicitly requested', () => { + const { warnings, effectiveSelectedFields } = + buildEffectiveSelectedFields({ + select: ['searchVector'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: defaultSelectedFields, + }); + + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('searchVector'); + expect(effectiveSelectedFields).not.toHaveProperty('searchVector'); + }); + }); + + describe('blocknote sub-field exclusion for RICH_TEXT fields', () => { + it('should strip blocknote from RICH_TEXT field sub-fields', () => { + const { effectiveSelectedFields } = buildEffectiveSelectedFields({ + select: ['*'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: defaultFlatObjectMetadata, + flatFieldMetadataMaps: defaultFlatFieldMetadataMaps, + selectedFields: { + id: true, + richText: { blocknote: true, markdown: true }, + }, + }); + + const richTextFields = + effectiveSelectedFields.richText as CommonSelectedFields; + + expect(richTextFields).not.toHaveProperty('blocknote'); + expect(richTextFields).toHaveProperty('markdown'); + }); + + it('should keep blocknote when the field type is not RICH_TEXT', () => { + const nonRichTextMaps = buildFlatFieldMetadataMaps([ + { id: FIELD_IDS.name, name: 'name', type: FieldMetadataType.TEXT }, + { + id: FIELD_IDS.richText, + name: 'richText', + type: FieldMetadataType.TEXT, + }, + ]); + const nonRichTextObjectMetadata = buildFlatObjectMetadata( + FIELD_IDS.name, + [FIELD_IDS.name, FIELD_IDS.richText], + ); + + const { effectiveSelectedFields } = buildEffectiveSelectedFields({ + select: ['*'], + filter: undefined, + orderBy: undefined, + objectName: 'person', + flatObjectMetadata: nonRichTextObjectMetadata, + flatFieldMetadataMaps: nonRichTextMaps, + selectedFields: { + id: true, + name: true, + richText: { blocknote: true, markdown: true }, + }, + }); + + const richTextFields = + effectiveSelectedFields.richText as CommonSelectedFields; + + expect(richTextFields).toHaveProperty('blocknote'); + expect(richTextFields).toHaveProperty('markdown'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util.ts new file mode 100644 index 0000000000..1e03eb7b76 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/build-effective-selected-fields.util.ts @@ -0,0 +1,258 @@ +import Fuse from 'fuse.js'; +import { FieldMetadataType } from 'twenty-shared/types'; + +import { isNull, isObject } from '@sniptt/guards'; +import { type CommonSelectedFields } from 'src/engine/api/common/types/common-selected-fields-result.type'; +import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { isDefined } from 'twenty-shared/utils'; + +const SEARCH_VECTOR_FIELD = 'searchVector'; +const LOGICAL_OPERATORS = new Set(['and', 'or', 'not']); + +const SUB_FIELDS_TO_EXCLUDE_BY_FIELD_TYPE: Partial< + Record> +> = { + [FieldMetadataType.RICH_TEXT]: new Set(['blocknote']), +}; + +const buildSelectedField = ( + rawSelect: string[], + filterFieldNames: string[], + orderByFieldNames: string[], + allSelectableFieldNames: string[], + labelIdentifierFieldName: string, + objectName: string, +): { select: string[]; warnings: string[] } => { + const cleanFieldNames = allSelectableFieldNames.filter( + (name) => name !== SEARCH_VECTOR_FIELD, + ); + + const implicitFields = [ + 'id', + labelIdentifierFieldName, + ...filterFieldNames, + ...orderByFieldNames, + ].filter((name) => cleanFieldNames.includes(name)); + + if (rawSelect.includes('*')) { + return { select: cleanFieldNames, warnings: [] }; + } + + const warnings: string[] = []; + const validFields: string[] = [...implicitFields]; + + for (const requestedName of rawSelect) { + if (implicitFields.includes(requestedName)) { + continue; + } + + if (cleanFieldNames.includes(requestedName)) { + validFields.push(requestedName); + } else { + const suggestions = findSimilarFieldNames(requestedName, cleanFieldNames); + const hint = + suggestions.length > 0 + ? ` Did you mean: ${suggestions.map((s) => `'${s}'`).join(', ')}?` + : ''; + + warnings.push( + `Field '${requestedName}' not found on ${objectName}.${hint}`, + ); + } + } + + return { select: [...new Set(validFields)], warnings }; +}; + +const extractFilterFieldNames = ( + filter: + | Record + | Record[] + | Partial + | Partial[] + | undefined, +): string[] => { + if (Array.isArray(filter)) { + return filter.flatMap((filterItem) => extractFilterFieldNames(filterItem)); + } + + if (!isDefined(filter)) { + return []; + } + + return Object.entries(filter).flatMap(([key, value]) => { + if (LOGICAL_OPERATORS.has(key)) { + return extractFilterFieldNames( + value as Record | Record[], + ); + } + + return [key]; + }); +}; + +const extractOrderByFieldNames = (orderBy: unknown): string[] => { + if (!Array.isArray(orderBy)) { + return []; + } + + return orderBy.flatMap((item) => (isObject(item) ? Object.keys(item) : [])); +}; + +const buildSelectedFieldsOverride = ( + select: string[], + allSelectableFields: CommonSelectedFields, + fieldNameToType: Map, +): CommonSelectedFields => { + const fieldsToInclude = new Set([...select, 'id']); + + const fieldsToProcess = Object.fromEntries( + Object.entries(allSelectableFields).filter(([fieldName]) => + fieldsToInclude.has(fieldName), + ), + ); + + return stripSubFieldsByType(fieldsToProcess, fieldNameToType); +}; + +export const buildEffectiveSelectedFields = ({ + select, + filter, + orderBy, + objectName, + flatObjectMetadata, + flatFieldMetadataMaps, + selectedFields, +}: { + select: string[]; + filter?: + | Record + | Record[] + | Partial + | Partial[]; + orderBy: unknown; + objectName: string; + flatObjectMetadata: FlatObjectMetadata; + flatFieldMetadataMaps: FlatEntityMaps; + selectedFields: CommonSelectedFields; +}): { effectiveSelectedFields: CommonSelectedFields; warnings: string[] } => { + const filterFieldNames = extractFilterFieldNames(filter); + const orderByFieldNames = extractOrderByFieldNames(orderBy); + + const labelIdentifierField = flatObjectMetadata.labelIdentifierFieldMetadataId + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: flatObjectMetadata.labelIdentifierFieldMetadataId, + }) + : undefined; + + const labelIdentifierFieldName = labelIdentifierField?.name ?? 'id'; + + const { select: cleanSelect, warnings } = buildSelectedField( + select, + filterFieldNames, + orderByFieldNames, + Object.keys(selectedFields), + labelIdentifierFieldName, + objectName, + ); + + const fieldNameToType = buildFieldNameToTypeMap( + flatObjectMetadata, + flatFieldMetadataMaps, + ); + + return { + effectiveSelectedFields: buildSelectedFieldsOverride( + cleanSelect, + selectedFields, + fieldNameToType, + ), + warnings, + }; +}; + +const buildFieldNameToTypeMap = ( + flatObjectMetadata: FlatObjectMetadata, + flatFieldMetadataMaps: FlatEntityMaps, +): Map => { + const map = new Map(); + + for (const fieldId of flatObjectMetadata.fieldIds) { + const field = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityId: fieldId, + }); + + if (isDefined(field)) { + map.set(field.name, field.type); + } + } + + return map; +}; + +const stripSubFieldsByType = ( + fields: CommonSelectedFields, + fieldNameToType: Map, +): CommonSelectedFields => { + const result: CommonSelectedFields = {}; + + for (const [fieldName, fieldValue] of Object.entries(fields)) { + result[fieldName] = stripFieldSubFieldsByType( + fieldName, + fieldValue, + fieldNameToType, + ); + } + + return result; +}; + +const stripFieldSubFieldsByType = ( + fieldName: string, + fieldValue: boolean | CommonSelectedFields, + fieldNameToType: Map, +): boolean | CommonSelectedFields => { + if (!isObject(fieldValue) || isNull(fieldValue)) { + return fieldValue; + } + + const fieldType = fieldNameToType.get(fieldName); + const subFieldsToExclude = isDefined(fieldType) + ? SUB_FIELDS_TO_EXCLUDE_BY_FIELD_TYPE[fieldType] + : undefined; + + if (!isDefined(subFieldsToExclude)) { + return fieldValue; + } + + const stripped: CommonSelectedFields = {}; + + for (const [subFieldName, subFieldValue] of Object.entries(fieldValue)) { + if (!subFieldsToExclude.has(subFieldName)) { + stripped[subFieldName] = subFieldValue as boolean; + } + } + + return stripped; +}; + +const findSimilarFieldNames = ( + name: string, + fieldNames: string[], +): string[] => { + const fuse = new Fuse(fieldNames, { + includeScore: true, + threshold: 0.4, + }); + + return fuse + .search(name) + .slice(0, 3) + .map((result) => result.item); +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts index 25c87a72c2..3138b02ed0 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util.ts @@ -9,10 +9,11 @@ export const generateUpdateManyRecordInputSchema = ( objectMetadata: ObjectMetadataForToolSchema, restrictedFields?: RestrictedFieldsPermissions, ) => { - const { filterSchema } = generateRecordFilterSchema( + const { filterSchema } = generateRecordFilterSchema({ objectMetadata, restrictedFields, - ); + additionalExcludedFieldNames: ['createdAt', 'updatedAt'], + }); const dataSchema = generateRecordPropertiesZodSchema( objectMetadata, diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/utils/to-tool-json-schema.util.ts b/packages/twenty-server/src/engine/core-modules/record-crud/utils/to-tool-json-schema.util.ts new file mode 100644 index 0000000000..d1643b7b6f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/utils/to-tool-json-schema.util.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +// Converts a Zod schema to a lean JSON Schema for LLM tool optimised consumption. +export const toToolJsonSchema = (schema: z.ZodTypeAny): object => { + const result = z.toJSONSchema(schema, { + io: 'input', + reused: 'ref', + override(ctx) { + if (!ctx.jsonSchema) { + return; + } + + if (ctx.jsonSchema.type === 'integer') { + delete ctx.jsonSchema.minimum; + delete ctx.jsonSchema.maximum; + } + + if (ctx.jsonSchema.format && ctx.jsonSchema.pattern) { + delete ctx.jsonSchema.pattern; + } + }, + }) as Record; + + delete result['$schema']; + + return result; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema.ts index c3173051a7..015d9cdf36 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/bulk-delete-tool.zod-schema.ts @@ -1,17 +1,25 @@ +import { type RestrictedFieldsPermissions } from 'twenty-shared/types'; import { z } from 'zod'; -export const BulkDeleteToolInputSchema = z.object({ - filter: z - .object({ - id: z - .object({ - in: z - .array(z.string().uuid()) - .describe('Array of record IDs to delete'), - }) - .describe('Filter to select records to delete'), - }) - .describe('Filter criteria to select records for bulk delete'), -}); +import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema'; -export type BulkDeleteToolInput = z.infer; +export const generateBulkDeleteToolInputSchema = ( + objectMetadata: ObjectMetadataForToolSchema, + restrictedFields?: RestrictedFieldsPermissions, +) => { + const { filterSchema } = generateRecordFilterSchema({ + objectMetadata, + restrictedFields, + }); + + return z.object({ + filter: filterSchema.describe( + 'Filter to select which records to delete. Supports field-level filters and logical operators (or, and, not). WARNING: A broad filter may delete many records at once. Always verify the filter scope with a find query first.', + ), + }); +}; + +export type BulkDeleteToolInput = { + filter: Record; +}; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts index 933f0b3913..80bc9fb486 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema.ts @@ -6,127 +6,47 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; +import { + AddressFilterSchema, + ArrayFieldFilterSchema, + BooleanFilterSchema, + CurrencyFilterSchema, + DateFilterSchema, + DefaultFilterSchema, + EmailsFilterSchema, + FullNameFilterSchema, + LinksFilterSchema, + NullCheckEnum, + NumberFilterSchema, + PhonesFilterSchema, + TextFilterSchema, + UuidFilterSchema, +} from 'src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema'; -const NullCheckEnum = z.enum(['NULL', 'NOT_NULL']); +export { NullCheckEnum }; export const generateFieldFilterZodSchema = ( field: FieldMetadataEntity | FlatFieldMetadata, ): z.ZodTypeAny | null => { switch (field.type) { case FieldMetadataType.UUID: - return z - .object({ - eq: z.string().uuid().optional().describe('Equals'), - neq: z.string().uuid().optional().describe('Not equals'), - in: z - .array(z.string().uuid()) - .optional() - .describe('In array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (UUID field)`); + return UuidFilterSchema; case FieldMetadataType.TEXT: case FieldMetadataType.RICH_TEXT: - return z - .object({ - eq: z.string().optional().describe('Equals'), - neq: z.string().optional().describe('Not equals'), - in: z.array(z.string()).optional().describe('In array of values'), - like: z - .string() - .optional() - .describe('Case-sensitive pattern match (use % for wildcards)'), - ilike: z - .string() - .optional() - .describe('Case-insensitive pattern match (use % for wildcards)'), - startsWith: z.string().optional().describe('Starts with'), - endsWith: z.string().optional().describe('Ends with'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (text field)`); + return TextFilterSchema; case FieldMetadataType.NUMBER: case FieldMetadataType.NUMERIC: case FieldMetadataType.POSITION: - return z - .object({ - eq: z.number().optional().describe('Equals'), - neq: z.number().optional().describe('Not equals'), - gt: z.number().optional().describe('Greater than'), - gte: z.number().optional().describe('Greater than or equal'), - lt: z.number().optional().describe('Less than'), - lte: z.number().optional().describe('Less than or equal'), - in: z.array(z.number()).optional().describe('In array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (number field)`); + return NumberFilterSchema; case FieldMetadataType.BOOLEAN: - return z - .object({ - eq: z.boolean().optional().describe('Equals'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (boolean field)`); + return BooleanFilterSchema; case FieldMetadataType.DATE_TIME: case FieldMetadataType.DATE: - return z - .object({ - eq: z - .string() - .datetime() - .optional() - .describe('Equals (ISO datetime string)'), - neq: z - .string() - .datetime() - .optional() - .describe('Not equals (ISO datetime string)'), - gt: z - .string() - .datetime() - .optional() - .describe('Greater than (ISO datetime string)'), - gte: z - .string() - .datetime() - .optional() - .describe('Greater than or equal (ISO datetime string)'), - lt: z - .string() - .datetime() - .optional() - .describe('Less than (ISO datetime string)'), - lte: z - .string() - .datetime() - .optional() - .describe('Less than or equal (ISO datetime string)'), - in: z - .array(z.string().datetime()) - .optional() - .describe('In array of values (ISO datetime strings)'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (date field)`); + return DateFilterSchema; case FieldMetadataType.SELECT: { const enumValues = @@ -143,12 +63,9 @@ export const generateFieldFilterZodSchema = ( eq: selectEnum.optional().describe('Equals'), neq: selectEnum.optional().describe('Not equals'), in: z.array(selectEnum).optional().describe('In array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), + is: NullCheckEnum.optional(), }) - .optional() - .describe(`Filter by ${field.name} (select field)`); + .optional(); } case FieldMetadataType.MULTI_SELECT: { @@ -167,13 +84,10 @@ export const generateFieldFilterZodSchema = ( .array(multiSelectEnum) .optional() .describe('Contains any of these values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), + is: NullCheckEnum.optional(), isEmptyArray: z.boolean().optional().describe('Is empty array'), }) - .optional() - .describe(`Filter by ${field.name} (multi-select field)`); + .optional(); } case FieldMetadataType.RATING: { @@ -190,350 +104,47 @@ export const generateFieldFilterZodSchema = ( .object({ eq: ratingEnum.optional().describe('Equals'), in: z.array(ratingEnum).optional().describe('In array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), + is: NullCheckEnum.optional(), }) - .optional() - .describe(`Filter by ${field.name} (rating field)`); + .optional(); } case FieldMetadataType.ARRAY: - return z - .object({ - containsIlike: z - .string() - .optional() - .describe('Contains case-insensitive substring'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - isEmptyArray: z.boolean().optional().describe('Is empty array'), - }) - .optional() - .describe(`Filter by ${field.name} (array field)`); + return ArrayFieldFilterSchema; case FieldMetadataType.CURRENCY: - return z - .object({ - amountMicros: z - .object({ - eq: z.number().optional().describe('Amount equals'), - neq: z.number().optional().describe('Amount not equals'), - gt: z.number().optional().describe('Amount greater than'), - gte: z - .number() - .optional() - .describe('Amount greater than or equal'), - lt: z.number().optional().describe('Amount less than'), - lte: z.number().optional().describe('Amount less than or equal'), - in: z - .array(z.number()) - .optional() - .describe('Amount in array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty amount. Use "NULL" to find records with no amount, "NOT_NULL" for records with an amount', - ), - }) - .optional() - .describe('Filter by amount'), - currencyCode: z - .object({ - eq: z.string().optional().describe('Currency code equals'), - neq: z.string().optional().describe('Currency code not equals'), - in: z - .array(z.string()) - .optional() - .describe('Currency code in array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty currency code. Use "NULL" to find records with no currency code, "NOT_NULL" for records with a currency code', - ), - }) - .optional() - .describe('Filter by currency code'), - }) - .optional() - .describe(`Filter by ${field.name} (currency field)`); + return CurrencyFilterSchema; case FieldMetadataType.FULL_NAME: - return z - .object({ - firstName: z - .object({ - eq: z.string().optional().describe('First name equals'), - neq: z.string().optional().describe('First name not equals'), - like: z - .string() - .optional() - .describe('First name case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('First name case-insensitive pattern match'), - startsWith: z - .string() - .optional() - .describe('First name starts with'), - endsWith: z.string().optional().describe('First name ends with'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty first name. Use "NULL" to find records with no first name, "NOT_NULL" for records with a first name', - ), - }) - .optional() - .describe('Filter by first name'), - lastName: z - .object({ - eq: z.string().optional().describe('Last name equals'), - neq: z.string().optional().describe('Last name not equals'), - like: z - .string() - .optional() - .describe('Last name case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Last name case-insensitive pattern match'), - startsWith: z - .string() - .optional() - .describe('Last name starts with'), - endsWith: z.string().optional().describe('Last name ends with'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty last name. Use "NULL" to find records with no last name, "NOT_NULL" for records with a last name', - ), - }) - .optional() - .describe('Filter by last name'), - }) - .optional() - .describe(`Filter by ${field.name} (full name field)`); + return FullNameFilterSchema; case FieldMetadataType.ADDRESS: - return z - .object({ - addressStreet1: z - .object({ - eq: z.string().optional().describe('Street 1 equals'), - neq: z.string().optional().describe('Street 1 not equals'), - like: z - .string() - .optional() - .describe('Street 1 case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Street 1 case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty street 1. Use "NULL" to find records with no street 1, "NOT_NULL" for records with a street 1', - ), - }) - .optional() - .describe('Filter by street 1'), - addressCity: z - .object({ - eq: z.string().optional().describe('City equals'), - neq: z.string().optional().describe('City not equals'), - like: z - .string() - .optional() - .describe('City case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('City case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty city. Use "NULL" to find records with no city, "NOT_NULL" for records with a city', - ), - }) - .optional() - .describe('Filter by city'), - addressCountry: z - .object({ - eq: z.string().optional().describe('Country equals'), - neq: z.string().optional().describe('Country not equals'), - like: z - .string() - .optional() - .describe('Country case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Country case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty country. Use "NULL" to find records with no country, "NOT_NULL" for records with a country', - ), - }) - .optional() - .describe('Filter by country'), - }) - .optional() - .describe(`Filter by ${field.name} (address field)`); + return AddressFilterSchema; case FieldMetadataType.EMAILS: - return z - .object({ - primaryEmail: z - .object({ - eq: z - .string() - .email() - .optional() - .describe('Primary email equals'), - neq: z - .string() - .email() - .optional() - .describe('Primary email not equals'), - like: z - .string() - .optional() - .describe('Primary email case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Primary email case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty primary email. Use "NULL" to find records with no primary email, "NOT_NULL" for records with a primary email', - ), - }) - .optional() - .describe('Filter by primary email'), - }) - .optional() - .describe(`Filter by ${field.name} (emails field)`); + return EmailsFilterSchema; case FieldMetadataType.PHONES: - return z - .object({ - primaryPhoneNumber: z - .object({ - eq: z.string().optional().describe('Primary phone number equals'), - neq: z - .string() - .optional() - .describe('Primary phone number not equals'), - like: z - .string() - .optional() - .describe('Primary phone number case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe( - 'Primary phone number case-insensitive pattern match', - ), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty primary phone number. Use "NULL" to find records with no primary phone number, "NOT_NULL" for records with a primary phone number', - ), - }) - .optional() - .describe('Filter by primary phone number'), - }) - .optional() - .describe(`Filter by ${field.name} (phones field)`); + return PhonesFilterSchema; case FieldMetadataType.LINKS: - return z - .object({ - primaryLinkUrl: z - .object({ - eq: z - .string() - .url() - .optional() - .describe('Primary link URL equals'), - neq: z - .string() - .url() - .optional() - .describe('Primary link URL not equals'), - like: z - .string() - .optional() - .describe('Primary link URL case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Primary link URL case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty primary link URL. Use "NULL" to find records with no primary link URL, "NOT_NULL" for records with a primary link URL', - ), - }) - .optional() - .describe('Filter by primary link URL'), - }) - .optional() - .describe(`Filter by ${field.name} (links field)`); + return LinksFilterSchema; case FieldMetadataType.RELATION: if ( isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) && field.settings?.relationType === RelationType.MANY_TO_ONE ) { - const fieldName = `${field.name}Id`; - - return z - .object({ - eq: z - .string() - .uuid() - .optional() - .describe('Related record ID equals'), - neq: z - .string() - .uuid() - .optional() - .describe('Related record ID not equals'), - in: z - .array(z.string().uuid()) - .optional() - .describe('Related record ID in array of values'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty related record. Use "NULL" to find records with no relation, "NOT_NULL" for records with a relation', - ), - }) - .optional() - .describe(`Filter by ${fieldName} (relation field)`); + return UuidFilterSchema; } return null; case FieldMetadataType.RAW_JSON: case FieldMetadataType.FILES: - return z - .object({ - eq: z.string().optional().describe('Raw JSON equals'), - neq: z.string().optional().describe('Raw JSON not equals'), - like: z - .string() - .optional() - .describe('Raw JSON case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Raw JSON case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (raw JSON field)`); + return DefaultFilterSchema; default: - return z - .object({ - eq: z.string().optional().describe('Equals'), - neq: z.string().optional().describe('Not equals'), - like: z.string().optional().describe('Case-sensitive pattern match'), - ilike: z - .string() - .optional() - .describe('Case-insensitive pattern match'), - is: NullCheckEnum.optional().describe( - 'Check for missing or empty values. Use "NULL" to find records with no value, "NOT_NULL" for records with a value', - ), - }) - .optional() - .describe(`Filter by ${field.name} (string field)`); + return DefaultFilterSchema; } }; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema.ts index 4027a54b29..823b918989 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema.ts @@ -2,6 +2,14 @@ import { z } from 'zod'; export const FindOneToolInputSchema = z.object({ id: z.string().uuid().describe('The unique UUID of the record to retrieve'), + select: z + .array(z.string()) + .nonempty() + .describe( + 'Fields to include in the response. Required. ' + + "Use '*' to return all fields. " + + ' MANY_TO_ONE relations are referenced by their FK column (e.g. companyId).', + ), }); export type FindOneToolInput = z.infer; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts index ac34ddc35a..6737b1bc1c 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema.ts @@ -9,11 +9,10 @@ export const generateFindToolInputSchema = ( objectMetadata: ObjectMetadataForToolSchema, restrictedFields?: RestrictedFieldsPermissions, ) => { - const { filterShape, filterSchema } = generateRecordFilterSchema( + const { filterShape, filterSchema } = generateRecordFilterSchema({ objectMetadata, restrictedFields, - ); - + }); return z.object({ limit: z .number() @@ -31,8 +30,20 @@ export const generateFindToolInputSchema = ( .default(0) .describe('Number of records to skip (default: 0)'), orderBy: ObjectRecordOrderBySchema.describe( - 'Sort records by field(s). CRITICAL for "top N", "largest", "smallest" queries. Each item is an object with exactly ONE property: field name as key, sort direction as value. Example: [{"employees": "DescNullsLast"}] sorts employees descending. Use "DescNullsLast" for top/largest, "AscNullsFirst" for bottom/smallest.', + 'Sort by field(s). ' + + 'Scalar fields: [{fieldName: "DescNullsLast"}]. ' + + 'Composite fields (name, address, currency, …): [{fieldName: {subFieldName: "AscNullsFirst"}}] — e.g. [{"name": {"firstName": "AscNullsFirst"}}]. ' + + 'Never use dot-notation keys like "name.firstName". ' + + 'Use DescNullsLast for top/largest, AscNullsFirst for bottom/smallest.', ), + select: z + .array(z.string()) + .nonempty() + .describe( + `Fields to include in the response. Required. ` + + `Use '*' to return all fields, or list specific field names. ` + + `MANY_TO_ONE relations are referenced by their FK column (e.g. 'companyId'). `, + ), ...filterShape, or: z .array(filterSchema) diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts index 24ecad95ce..c7df57829d 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts @@ -147,10 +147,10 @@ export const generateGroupByToolInputSchema = ( groupByEntries as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]], ); - const { filterShape, filterSchema } = generateRecordFilterSchema( + const { filterShape, filterSchema } = generateRecordFilterSchema({ objectMetadata, restrictedFields, - ); + }); const availableAggregations = getAvailableAggregationsFromObjectFields( objectMetadata.fields.filter( diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema.ts index e50aa38d96..3ac0af0853 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema.ts @@ -6,20 +6,31 @@ export const OrderByDirectionEnum = z.enum([ 'DescNullsFirst', 'DescNullsLast', ]); +export const OrderByFieldValueSchema = z.union([ + OrderByDirectionEnum, + z.record(z.string(), OrderByDirectionEnum), +]); export const ObjectRecordOrderByItemSchema = z .object({}) - .catchall(OrderByDirectionEnum) + .catchall(OrderByFieldValueSchema) .refine((obj) => Object.keys(obj).length === 1, { message: 'Each orderBy item must specify exactly one field', }) .describe( - 'Object with exactly ONE property: field name as key, OrderByDirection as value. Example: {"employees": "DescNullsLast"}', + 'Object with exactly ONE property. ' + + 'For scalar fields use a direction string: {"employees": "DescNullsLast"}. ' + + 'For composite fields (e.g. name, address, currency) use a nested object with the sub-field: {"name": {"firstName": "AscNullsFirst"}}. ' + + 'Never use dot-notation keys like "name.firstName".', ); export const ObjectRecordOrderBySchema = z .array(ObjectRecordOrderByItemSchema) .optional() .describe( - 'Array of sort criteria. Each item sorts by one field. Use "DescNullsLast" for descending (top/largest), "AscNullsFirst" for ascending (bottom/smallest). Example: [{"employees": "DescNullsLast"}]', + 'Array of sort criteria. Each item sorts by one field. ' + + 'Scalar fields: [{"employees": "DescNullsLast"}]. ' + + 'Composite fields (name, address, currency, …): [{"name": {"firstName": "AscNullsFirst"}}]. ' + + 'Use "DescNullsLast" for descending (top/largest), "AscNullsFirst" for ascending (bottom/smallest). ' + + 'Never use dot-notation keys like "name.firstName".', ); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts index e4ff1401f0..08818473f2 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts @@ -11,18 +11,29 @@ import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modul import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; // Builds the per-field filter shape and full recursive filter schema -// for a given object metadata, reusable across find and updateMany tools -export const generateRecordFilterSchema = ( - objectMetadata: ObjectMetadataForToolSchema, - restrictedFields?: RestrictedFieldsPermissions, -): { +// for a given object metadata, reusable across find, delete, and updateMany tools +export const generateRecordFilterSchema = ({ + objectMetadata, + restrictedFields, + additionalExcludedFieldNames = [], +}: { + objectMetadata: ObjectMetadataForToolSchema; + restrictedFields?: RestrictedFieldsPermissions; + additionalExcludedFieldNames?: string[]; +}): { filterShape: Record; filterSchema: z.ZodTypeAny; } => { const filterShape: Record = {}; objectMetadata.fields.forEach((field) => { - if (shouldExcludeFieldFromAgentToolSchema(field)) { + if ( + shouldExcludeFieldFromAgentToolSchema( + field, + true, + additionalExcludedFieldNames, + ) + ) { return; } diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts index 7dc8d9d72d..859a1f3cee 100644 --- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema.ts @@ -11,6 +11,24 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { filesFieldSchema } from 'src/engine/api/common/common-args-processors/data-arg-processor/validator-utils/validate-files-field-or-throw.util'; import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type'; +import { + AddressValueOptionalSchema, + AddressValueSchema, + CurrencyValueOptionalSchema, + CurrencyValueSchema, + EmailsValueOptionalSchema, + EmailsValueSchema, + FullNameValueOptionalSchema, + FullNameValueSchema, + LinksValueOptionalSchema, + LinksValueSchema, + PhonesValueOptionalSchema, + PhonesValueSchema, + RichTextValueOptionalSchema, + RichTextValueSchema, + UuidValueOptionalSchema, + UuidValueSchema, +} from 'src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; @@ -34,7 +52,7 @@ const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => { const getFieldZodType = (field: FlatFieldMetadata): z.ZodTypeAny => { switch (field.type) { case FieldMetadataType.UUID: - return z.string().uuidv4(); + return UuidValueSchema; case FieldMetadataType.TEXT: return z.string(); @@ -101,11 +119,9 @@ export const generateRecordPropertiesZodSchema = ( isRelationOrMorphRelation && field.settings?.relationType === RelationType.MANY_TO_ONE ) { - const uuidSchema = z.uuidv4(); - shape[`${field.name}Id`] = field.isNullable - ? uuidSchema.optional() - : uuidSchema; + ? UuidValueOptionalSchema + : UuidValueSchema; return; } @@ -160,47 +176,49 @@ export const generateRecordPropertiesZodSchema = ( break; } - case FieldMetadataType.LINKS: - fieldSchema = z.object({ - primaryLinkLabel: z.string().optional(), - primaryLinkUrl: z.string().url().optional(), - secondaryLinks: z - .array( - z.object({ - url: z.string().url(), - label: z.string(), - }), - ) - .optional(), - }); - break; + case FieldMetadataType.LINKS: { + const baseSchema = field.isNullable + ? LinksValueOptionalSchema + : LinksValueSchema; - case FieldMetadataType.CURRENCY: - fieldSchema = z.object({ - amountMicros: z.number().optional(), - currencyCode: z.string().optional(), - }); - break; + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } - case FieldMetadataType.FULL_NAME: - fieldSchema = z.object({ - firstName: z.string().optional(), - lastName: z.string().optional(), - }); - break; + case FieldMetadataType.CURRENCY: { + const baseSchema = field.isNullable + ? CurrencyValueOptionalSchema + : CurrencyValueSchema; - case FieldMetadataType.ADDRESS: - fieldSchema = z.object({ - addressStreet1: z.string().optional(), - addressStreet2: z.string().optional(), - addressCity: z.string().optional(), - addressPostcode: z.string().optional(), - addressState: z.string().optional(), - addressCountry: z.string().optional(), - addressLat: z.number().optional(), - addressLng: z.number().optional(), - }); - break; + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } + + case FieldMetadataType.FULL_NAME: { + const baseSchema = field.isNullable + ? FullNameValueOptionalSchema + : FullNameValueSchema; + + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } + + case FieldMetadataType.ADDRESS: { + const baseSchema = field.isNullable + ? AddressValueOptionalSchema + : AddressValueSchema; + + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } case FieldMetadataType.ACTOR: fieldSchema = z.object({ @@ -226,28 +244,38 @@ export const generateRecordPropertiesZodSchema = ( }); break; - case FieldMetadataType.EMAILS: - fieldSchema = z.object({ - primaryEmail: z.string().email().optional(), - additionalEmails: z.array(z.string().email()).optional(), - }); - break; + case FieldMetadataType.EMAILS: { + const baseSchema = field.isNullable + ? EmailsValueOptionalSchema + : EmailsValueSchema; - case FieldMetadataType.PHONES: - fieldSchema = z.object({ - primaryPhoneNumber: z.string().optional(), - primaryPhoneCountryCode: z.string().optional(), - primaryPhoneCallingCode: z.string().optional(), - additionalPhones: z.array(z.string()).optional(), - }); - break; + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } - case FieldMetadataType.RICH_TEXT: - fieldSchema = z.object({ - markdown: z.string().optional(), - blocknote: z.string().optional(), - }); - break; + case FieldMetadataType.PHONES: { + const baseSchema = field.isNullable + ? PhonesValueOptionalSchema + : PhonesValueSchema; + + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } + + case FieldMetadataType.RICH_TEXT: { + const baseSchema = field.isNullable + ? RichTextValueOptionalSchema + : RichTextValueSchema; + + shape[field.name] = field.description + ? baseSchema.describe(field.description) + : baseSchema; + return; + } case FieldMetadataType.FILES: fieldSchema = filesFieldSchema; diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts new file mode 100644 index 0000000000..f3c13d1799 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-filter-defs.zod-schema.ts @@ -0,0 +1,243 @@ +import { z } from 'zod'; + +export const NullCheckEnum = z + .enum(['NULL', 'NOT_NULL']) + .describe('Is null or not null'); + +export const TextFilterSchema = z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + in: z.array(z.string()).optional().describe('In array'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + startsWith: z.string().optional().describe('Starts with'), + endsWith: z.string().optional().describe('Ends with'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const NumberFilterSchema = z + .object({ + eq: z.number().optional().describe('Equals'), + neq: z.number().optional().describe('Not equals'), + gt: z.number().optional().describe('>'), + gte: z.number().optional().describe('>='), + lt: z.number().optional().describe('<'), + lte: z.number().optional().describe('<='), + in: z.array(z.number()).optional().describe('In array'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const DateFilterSchema = z + .object({ + eq: z.string().datetime().optional().describe('Equals (ISO datetime)'), + neq: z.string().datetime().optional().describe('Not equals (ISO datetime)'), + gt: z.string().datetime().optional().describe('> ISO datetime'), + gte: z.string().datetime().optional().describe('>= ISO datetime'), + lt: z.string().datetime().optional().describe('< ISO datetime'), + lte: z.string().datetime().optional().describe('<= ISO datetime'), + in: z + .array(z.string().datetime()) + .optional() + .describe('In array (ISO datetimes)'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const BooleanFilterSchema = z + .object({ + eq: z.boolean().optional().describe('Equals'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const UuidFilterSchema = z + .object({ + eq: z.string().uuid().optional().describe('Equals'), + neq: z.string().uuid().optional().describe('Not equals'), + in: z.array(z.string().uuid()).optional().describe('In array of values'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const DefaultFilterSchema = z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(); + +export const ArrayFieldFilterSchema = z + .object({ + containsIlike: z + .string() + .optional() + .describe('Contains case-insensitive substring'), + is: NullCheckEnum.optional(), + isEmptyArray: z.boolean().optional().describe('Is empty array'), + }) + .optional(); + +// Composite filter schemas — A1.3 +// Each is a shared constant so all LINKS/ADDRESS/etc. fields share one $def. + +export const LinksFilterSchema = z + .object({ + primaryLinkUrl: z + .object({ + eq: z.string().url().optional().describe('Equals'), + neq: z.string().url().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + +export const AddressFilterSchema = z + .object({ + addressStreet1: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + addressCity: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + addressCountry: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + +export const FullNameFilterSchema = z + .object({ + firstName: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + startsWith: z.string().optional().describe('Starts with'), + endsWith: z.string().optional().describe('Ends with'), + is: NullCheckEnum.optional(), + }) + .optional(), + lastName: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + startsWith: z.string().optional().describe('Starts with'), + endsWith: z.string().optional().describe('Ends with'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + +export const EmailsFilterSchema = z + .object({ + primaryEmail: z + .object({ + eq: z.string().email().optional().describe('Equals'), + neq: z.string().email().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + +export const PhonesFilterSchema = z + .object({ + primaryPhoneNumber: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + like: z.string().optional().describe('LIKE (% wildcard)'), + ilike: z + .string() + .optional() + .describe('ILIKE (% wildcard, case-insensitive)'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); + +export const CurrencyFilterSchema = z + .object({ + amountMicros: z + .object({ + eq: z.number().optional().describe('Equals'), + neq: z.number().optional().describe('Not equals'), + gt: z.number().optional().describe('>'), + gte: z.number().optional().describe('>='), + lt: z.number().optional().describe('<'), + lte: z.number().optional().describe('<='), + in: z.array(z.number()).optional().describe('In array'), + is: NullCheckEnum.optional(), + }) + .optional(), + currencyCode: z + .object({ + eq: z.string().optional().describe('Equals'), + neq: z.string().optional().describe('Not equals'), + in: z.array(z.string()).optional().describe('In array'), + is: NullCheckEnum.optional(), + }) + .optional(), + }) + .optional(); diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema.ts new file mode 100644 index 0000000000..11e7c1bea9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/shared-value-defs.zod-schema.ts @@ -0,0 +1,62 @@ +import { z } from 'zod'; + +export const UuidValueSchema = z.uuidv4(); +export const UuidValueOptionalSchema = UuidValueSchema.optional(); + +export const LinksValueSchema = z.object({ + primaryLinkLabel: z.string().optional(), + primaryLinkUrl: z.string().url().optional(), + secondaryLinks: z + .array( + z.object({ + url: z.string().url(), + label: z.string(), + }), + ) + .optional(), +}); +export const LinksValueOptionalSchema = LinksValueSchema.optional(); + +export const CurrencyValueSchema = z.object({ + amountMicros: z.number().optional(), + currencyCode: z.string().optional(), +}); +export const CurrencyValueOptionalSchema = CurrencyValueSchema.optional(); + +export const FullNameValueSchema = z.object({ + firstName: z.string().optional(), + lastName: z.string().optional(), +}); +export const FullNameValueOptionalSchema = FullNameValueSchema.optional(); + +export const AddressValueSchema = z.object({ + addressStreet1: z.string().optional(), + addressStreet2: z.string().optional(), + addressCity: z.string().optional(), + addressPostcode: z.string().optional(), + addressState: z.string().optional(), + addressCountry: z.string().optional(), + addressLat: z.number().optional(), + addressLng: z.number().optional(), +}); +export const AddressValueOptionalSchema = AddressValueSchema.optional(); + +export const EmailsValueSchema = z.object({ + primaryEmail: z.string().email().optional(), + additionalEmails: z.array(z.string().email()).optional(), +}); +export const EmailsValueOptionalSchema = EmailsValueSchema.optional(); + +export const PhonesValueSchema = z.object({ + primaryPhoneNumber: z.string().optional(), + primaryPhoneCountryCode: z.string().optional(), + primaryPhoneCallingCode: z.string().optional(), + additionalPhones: z.array(z.string()).optional(), +}); +export const PhonesValueOptionalSchema = PhonesValueSchema.optional(); + +export const RichTextValueSchema = z.object({ + markdown: z.string().optional(), + blocknote: z.string().optional(), +}); +export const RichTextValueOptionalSchema = RichTextValueSchema.optional(); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts index 4d78421f52..ef2578fa8c 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/__tests__/database-tool.provider.spec.ts @@ -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', ]), ); }); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index 78e7ab513a..350d8dc1c1 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -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}`) ); } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts index 542dd12154..ba09c7f9d4 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/services/tool-executor.service.ts @@ -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[], + 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, + authContext, + rolePermissionConfig: context.rolePermissionConfig, + }); + case 'group_by': { const { groupBy, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts index 7d7ef67bc5..658fc77320 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts @@ -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.`, }; } diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/types/database-crud-operation.type.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/types/database-crud-operation.type.ts index 20234ae6fa..bc326023a9 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/types/database-crud-operation.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/types/database-crud-operation.type.ts @@ -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'; diff --git a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts index 6df43bf0c0..372245e25d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/tools/code-interpreter-tool/twenty-mcp-helper.const.ts @@ -18,7 +18,7 @@ class TwentyMCP: search_help_center. These are the 4 surfaces exposed directly. - Workspace catalog: 250+ CRUD / view / workflow / dashboard tools - like find_companies, create_person, update_opportunity. These are + like find_many_companies, create_one_person, update_one_opportunity. These are reached through execute_tool as a dispatcher. call_tool(name, args) accepts both — catalog tools are routed via @@ -47,7 +47,7 @@ class TwentyMCP: """ Call any Twenty tool by name. - Catalog tools (find_companies, create_person, …) are routed + Catalog tools (find_many_companies, create_one_person, …) are routed through execute_tool. MCP-native tools are called directly. The execute_tool envelope { success, message, result } is unwrapped so you always get the inner tool's result back. @@ -60,7 +60,7 @@ class TwentyMCP: Tool result as parsed JSON Example: - companies = twenty.call_tool('find_companies', {'limit': 5}) + companies = twenty.call_tool('find_many_companies', {'limit': 5}) # companies == {'records': [...], 'count': '5'} """ if not self._available: @@ -114,7 +114,7 @@ class TwentyMCP: # \`twenty\` is a pre-built instance of the TwentyMCP class above. It is # already bound in this module scope — DO NOT \`import twenty\`. There is # no Python package by that name. Just use it directly, e.g.: -# companies = twenty.call_tool('find_companies', {'limit': 10}) +# companies = twenty.call_tool('find_many_companies', {'limit': 10}) # -------------------------------------------------------------------------- twenty = TwentyMCP() `; diff --git a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts index 801884350c..0f293afd4e 100644 --- a/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts +++ b/packages/twenty-server/src/engine/core-modules/tool/types/tool-output.type.ts @@ -5,6 +5,7 @@ export type ToolOutput = { message: string; error?: string; result?: T; + warnings?: string[]; status?: number; statusText?: string; headers?: Record; diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts index c4f9ccc29c..8313f60714 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const.ts @@ -7,7 +7,8 @@ export const WORKFLOW_SYSTEM_PROMPTS = { Tool usage strategy: - Chain multiple tools to solve complex tasks -- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, etc.) over looping single-item calls +- Prefer batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) over looping single-item calls +- Use \`upsert_many_*\` instead of \`update_many_*\` when records have different data to set individually, or when some records may not exist yet - If a tool fails, try alternative approaches - Use results from one tool to inform the next - Don't give up after first failure - be persistent diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts index 749d453f7c..c83a23e2bb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts @@ -29,11 +29,12 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need ## Database vs HTTP Tools -- Use database tools (find_*, create_*, update_*, delete_*) for ALL Twenty CRM data operations +- Use database tools (find_many_*, find_one_*, create_one_*, create_many_*, update_one_*, update_many_*, upsert_many_*, delete_one_*, delete_many_*) for ALL Twenty CRM data operations - NEVER guess or construct API URLs — always use the appropriate database tool - The \`http_request\` tool is ONLY for external third-party APIs (not for Twenty's own data) -- If you need to look up a record, learn and execute the corresponding find_one_* or find_many_* tool -- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results. +- If you need to look up a record by ID, use find_one_*; to search with filters, use find_many_* +- For comparative/grouped analytics questions (by/per/top/most/least/average/total/ranking), use \`group_by_*\` instead of \`find_many_*\`; if multiple metrics are needed, run multiple \`group_by_*\` calls with the same dimensions and merge results. +- **update_many_* vs upsert_many_***: use \`update_many_*\` when ALL matched records get the SAME data (e.g. mark all as closed). Use \`upsert_many_*\` when each record has different data to set, or when some records may not exist yet (insert-or-update per record). ## Data Efficiency @@ -41,7 +42,7 @@ For simple CRUD operations (find/create/update/delete a record), you do NOT need - Always apply filters to narrow results — don't fetch all records of a type. - Fetch one type of data at a time and check if you have what you need before fetching more. - Every record returned consumes context. Fetching too many records at once will cause failures. -- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, etc.) instead of looping single-item calls. +- For multiple items of the same type, use batch tools (\`create_many_*\`, \`update_many_*\`, \`upsert_many_*\`, etc.) instead of looping single-item calls. ## Tool Strategy diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts index 16ade63dab..3ea9fe2a02 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts @@ -281,7 +281,16 @@ ${preloadedList} const categoryLabel = this.getCategoryLabel(category); - sections.push(` + if (category === ToolCategory.DATABASE_CRUD) { + sections.push( + this.buildDatabaseCrudCatalogSection( + tools, + preloadedSet, + categoryLabel, + ), + ); + } else { + sections.push(` #### ${categoryLabel} (${tools.length} tools) ${tools .map((tool) => { @@ -290,6 +299,7 @@ ${tools return `- \`${tool.name}\`${status}`; }) .join('\n')}`); + } } sections.push(` @@ -300,6 +310,69 @@ ${tools return sections.join('\n'); } + private buildDatabaseCrudCatalogSection( + tools: ToolIndexEntry[], + preloadedSet: Set, + categoryLabel: string, + ): string { + const operationOrder: string[] = []; + const seenOps = new Set(); + + const objectToolsMap = new Map(); + const standaloneTools: ToolIndexEntry[] = []; + + for (const tool of tools) { + if (tool.objectName && tool.operation) { + const ops = objectToolsMap.get(tool.objectName) ?? []; + + ops.push(tool.operation); + objectToolsMap.set(tool.objectName, ops); + + if (!seenOps.has(tool.operation)) { + seenOps.add(tool.operation); + operationOrder.push(tool.operation); + } + } else { + standaloneTools.push(tool); + } + } + + const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`]; + + if (objectToolsMap.size > 0) { + const objectNames = [...objectToolsMap.keys()].sort(); + + lines.push(`Operations per object:`); + lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``)); + + lines.push(`\nObjects (${objectNames.length}):`); + lines.push(...objectNames.map((name) => `- \`${name}\``)); + + const findManyExample = tools.find((t) => t.operation === 'find_many'); + const findOneExample = tools.find( + (t) => + t.operation === 'find_one' && + t.objectName === findManyExample?.objectName, + ); + const examplePart = + findManyExample && findOneExample + ? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\`` + : ''; + + lines.push( + `\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`, + ); + } + + for (const tool of standaloneTools) { + const status = preloadedSet.has(tool.name) ? ' ✓' : ''; + + lines.push(`- \`${tool.name}\`${status}`); + } + + return lines.join('\n'); + } + private getCategoryLabel(category: ToolCategory): string { switch (category) { case ToolCategory.DATABASE_CRUD: diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts index a6fe4a3297..3d5d035859 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts @@ -4,13 +4,13 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m export const shouldExcludeFieldFromAgentToolSchema = ( field: FieldMetadataEntity | FlatFieldMetadata, excludeId = true, + additionalExcludedFieldNames: string[] = [], ): boolean => { const excludedFieldNames = [ - 'createdAt', - 'updatedAt', 'deletedAt', 'searchVector', 'createdBy', + ...additionalExcludedFieldNames, ]; if (excludeId) { diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index 001a61b8c7..71277e9ef2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -183,7 +183,7 @@ For the fields you will create, make sure to create a good variety of field type STEP 0: Present a plan to the user and wait for approval. - Use list_object_metadata_items to see all available objects in the workspace -- Use find_people (limit: 5) and find_companies (limit: 5) and find_opportunities (limit: 5) to understand the existing seed data shape +- Use find_many_people (limit: 5) and find_many_companies (limit: 5) and find_many_opportunities (limit: 5) to understand the existing seed data shape - Based on the user's business type, propose a plan that lists: - How People, Companies, and Opportunities map to the domain story (e.g. "People = Candidates", "Companies = Employers") - The 2–3 custom objects you will create, each with a one-line description of their role @@ -215,9 +215,9 @@ targetFieldIcon is like IconSomething, it's ok if it doesn't exist in the icon l STEP 6: Wait 3 seconds, for the backend side effects to be completed STEP 7: Rename and enrich the first N records of People, Companies, and Opportunities. -- Use find_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table +- Use find_many_people (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_many_companies (limit: 50, orderBy: [{ position: "AscNullsFirst" }]), find_many_opportunities (limit: 50, orderBy: [{ position: "AscNullsFirst" }]) to get the IDs of the first records in each table - Ordering by position ascending gives the earliest-inserted records, which are contiguous in the table — this keeps the demo data tightly grouped and makes the workspace feel coherent -- For each standard object, call update_people / update_companies / update_opportunities **individually per record** (one call per record) to set domain-relevant names and field values: +- For each standard object, call update_one_person / update_one_company / update_one_opportunity **individually per record** (one call per record) to set domain-relevant names and field values: - **People**: replace nameFirstName + nameLastName with realistic names that fit the domain role (e.g. for a law firm: "Sophie Martin", "James O'Brien"; for a clinic: "Dr. Clara Reyes", "Marco Bianchi"). Also set jobTitle to a domain-appropriate title. - **Companies**: replace name with realistic company names that fit the domain (e.g. for a law firm: "Ashford & Partners", "Nexus Legal Group"; for a clinic: "Meridian Health Clinic", "CarePoint Medical"). - **Opportunities**: replace name with a domain-relevant deal name (e.g. "Q2 retainer — Ashford & Partners", "New patient intake — Meridian Health"). @@ -636,9 +636,10 @@ print('Analysis complete!') instance of a class that has been pre-instantiated for you; just call methods on it directly. -Real catalog tools follow the pattern \`find_\` / \`find_one_\` / -\`create_\` / \`update_\` / \`delete_\` / -\`group_by_\` — e.g. \`find_companies\`, \`find_people\`, \`create_person\`. +Real catalog tools follow the pattern \`find_many_\` / \`find_one_\` / +\`create_one_\` / \`create_many_\` / \`update_one_\` / \`update_many_\` / +\`delete_one_\` / \`delete_many_\` / \`group_by_\` — +e.g. \`find_many_companies\`, \`find_one_company\`, \`create_one_person\`. Call \`twenty.list_tools()\` to discover exact names. Catalog tools are routed through \`execute_tool\` automatically, and the helper raises an Exception on server-side failures with the error message. @@ -651,14 +652,14 @@ for tool in tools[:5]: print(f"- {tool['name']}") # Find records — returns { 'records': [...], 'count': '5' } -companies = twenty.call_tool('find_companies', {'limit': 5, 'offset': 0}) +companies = twenty.call_tool('find_many_companies', {'limit': 5, 'offset': 0}) for c in companies['records']: print(c['name'], c.get('employees')) # Create a record — arguments match the tool's inputSchema directly, # no nested 'data' wrapper. Use twenty.call_tool('learn_tools', ...) to # inspect a schema if unsure. -result = twenty.call_tool('create_company', { +result = twenty.call_tool('create_one_company', { 'name': 'Acme Corp', 'domainName': {'primaryLinkUrl': 'https://acme.com'}, 'position': 'first', @@ -666,7 +667,7 @@ result = twenty.call_tool('create_company', { print(f"Created company id={result['id']}") # Update a record -twenty.call_tool('update_person', { +twenty.call_tool('update_one_person', { 'id': 'person-uuid-here', 'jobTitle': 'CEO', }) diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts index 7bb884b632..fcb4d22a2e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/record-crud/find-records.workflow-action.ts @@ -97,6 +97,7 @@ export class FindRecordsWorkflowAction implements WorkflowAction { limit: workflowActionInput.limit, authContext: executionContext.authContext, rolePermissionConfig: executionContext.rolePermissionConfig, + shouldBuildEffectiveSelectFields: false, }); if (!toolOutput.success) { diff --git a/packages/twenty-server/test/integration/ai/suites/mcp-tool-execution.integration-spec.ts b/packages/twenty-server/test/integration/ai/suites/mcp-tool-execution.integration-spec.ts index cd63e73b78..07c395db69 100644 --- a/packages/twenty-server/test/integration/ai/suites/mcp-tool-execution.integration-spec.ts +++ b/packages/twenty-server/test/integration/ai/suites/mcp-tool-execution.integration-spec.ts @@ -7,9 +7,9 @@ import { deleteRecordsByIds } from 'test/integration/utils/delete-records-by-ids const TEST_WORKSPACE_SCHEMA = 'workspace_1wgvd1injqtife6y4rvfbu3h5'; const TOOL_NAMES = { - createCompany: 'create_company', - createNote: 'create_note', - createNoteTarget: 'create_note_target', + createCompany: 'create_one_company', + createNote: 'create_one_note', + createNoteTarget: 'create_one_note_target', groupByNoteTargets: 'group_by_note_targets', } as const; @@ -135,15 +135,32 @@ describe('MCP tool execution (integration)', () => { it('should expose the morph-relation join columns as `${name}Id` UUID parameters', async () => { const inputSchema = await learnToolSchema(TOOL_NAMES.createNoteTarget); - const properties = ( - inputSchema as { - properties?: Record; - } - ).properties; + const schema = inputSchema as { + properties?: Record< + string, + { type?: string; format?: string; $ref?: string } + >; + $defs?: Record; + }; + + const properties = schema.properties; expect(properties).toBeDefined(); - expect(properties?.noteId).toMatchObject({ + const resolveProperty = ( + prop: { type?: string; format?: string; $ref?: string } | undefined, + ) => { + if (!prop) return undefined; + if (prop.$ref && schema.$defs) { + const defKey = prop.$ref.replace('#/$defs/', ''); + + return schema.$defs[defKey]; + } + + return prop; + }; + + expect(resolveProperty(properties?.noteId)).toMatchObject({ type: 'string', format: 'uuid', }); @@ -151,15 +168,15 @@ describe('MCP tool execution (integration)', () => { // Morph relations must be exposed as `${name}Id` UUIDs (the join column), // not as the relation name typed as string — the data-arg-processor only // accepts the join-column form for write operations. - expect(properties?.targetCompanyId).toMatchObject({ + expect(resolveProperty(properties?.targetCompanyId)).toMatchObject({ type: 'string', format: 'uuid', }); - expect(properties?.targetPersonId).toMatchObject({ + expect(resolveProperty(properties?.targetPersonId)).toMatchObject({ type: 'string', format: 'uuid', }); - expect(properties?.targetOpportunityId).toMatchObject({ + expect(resolveProperty(properties?.targetOpportunityId)).toMatchObject({ type: 'string', format: 'uuid', });