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

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

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