fix(ai) - optimize metadata CRUD tools (#21235)

Reduces output tokens for all 13 metadata tools by (~49%) based on
production sampling data.

GET tools (field + object metadata)

System fields are now returned as compact {id, name, type} instead of
the full ~20-key payload (opt-in includeFullSystemFields to get full
payload). System objects are similarly compacted to {id, nameSingular,
namePlural}.
Internal fields the agent never uses (searchVector, deletedAt, position,
updatedBy) are excluded entirely.
workspaceId and applicationId are hoisted into a response envelope
instead of being repeated on every record.
Null/default-false properties are stripped from custom field and object
payloads (e.g. options: null, settings: null, isUIReadOnly: false).

CUD tools (create/update/delete)

Create and update field tools now return {id, name, type, label} instead
of the full DTO.
Create and update object tools now return {id, nameSingular,
labelSingular} instead of the full DTO.
Delete tools return {id, success: true} instead of the full DTO of the
deleted entity.
Validation errors are grouped by message — e.g. 10 fields failing the
same check produce one line with all names instead of 10 identical
lines.

Learn schemas (all tools)

UUID pattern regex stripped from JSON schemas (keeps format: "uuid").
$schema and additionalProperties: false stripped from all generated
schemas.
All Zod .describe() annotations and tool descriptions shortened.
Skill & tool description updates:

All references to the removed list_object_metadata_items tool replaced
with get_object_metadata / get_field_metadata across skill instructions,
dashboard tools, view filter/sort tools, and MCP server instructions.
This commit is contained in:
Etienne
2026-06-08 13:55:29 +02:00
committed by GitHub
parent afec1f1332
commit b56fea69aa
24 changed files with 656 additions and 219 deletions
@@ -8,6 +8,7 @@ import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfa
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolCategory } from 'twenty-shared/ai';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { CodeInterpreterService } from 'src/engine/core-modules/code-interpreter/code-interpreter.service';
@@ -158,7 +159,7 @@ export class ActionToolProvider implements ToolProvider {
category: ToolCategory.ACTION,
icon: 'IconPlayerPlay',
...(includeSchemas && {
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
inputSchema: toToolJsonSchema(tool.inputSchema as z.ZodType),
}),
executionRef: { kind: 'static', toolId },
};
@@ -33,7 +33,6 @@ 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 {
@@ -133,7 +132,7 @@ export class DatabaseToolProvider implements ToolProvider {
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_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -152,7 +151,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Retrieve a single ${objectMetadata.labelSingular} by ID.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
inputSchema: toToolJsonSchema(FindOneToolInputSchema),
}),
executionRef: {
kind: 'database_crud',
@@ -202,7 +201,7 @@ export class DatabaseToolProvider implements ToolProvider {
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_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -221,7 +220,7 @@ export class DatabaseToolProvider implements ToolProvider {
description: `Create multiple ${objectMetadata.labelPlural} records in a single call. Provide an array of records, each containing the required fields. Maximum 20 records per call. Returns the created records.`,
category: ToolCategory.DATABASE_CRUD,
...(shouldIncludeSchema(`create_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -243,7 +242,7 @@ export class DatabaseToolProvider implements ToolProvider {
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_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
}),
@@ -262,7 +261,7 @@ export class DatabaseToolProvider implements ToolProvider {
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(
inputSchema: toToolJsonSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -284,7 +283,7 @@ export class DatabaseToolProvider implements ToolProvider {
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(`upsert_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
inputSchema: toToolJsonSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
@@ -12,12 +12,16 @@ export type LearnToolsAspect = z.infer<typeof learnToolsAspectSchema>;
export const learnToolsInputSchema = z.object({
toolNames: z
.array(z.string())
.describe('Exact tool names. Do not guess tool names.'),
.describe(
'Exact tool names. Do not guess tool names. Pass every tool you need to learn in this single array — do not make separate learn_tools calls per tool.',
),
aspects: z
.array(learnToolsAspectSchema)
.optional()
.default(['description', 'schema'])
.describe('What to learn: description, schema, or both.'),
.describe(
'What to learn: ["description"], ["schema"], or ["description", "schema"].',
),
});
export type LearnToolsInput = z.infer<typeof learnToolsInputSchema>;
@@ -40,7 +44,7 @@ export const createLearnToolsTool = (
excludeTools?: Set<string>,
) => ({
description:
'Get input schemas for tools. Call this with exact tool names to learn the required arguments before calling execute_tool.',
'Get input schemas for tools. Pass all the tool names you need in a single call (toolNames accepts an array) rather than calling learn_tools once per tool. Call this with exact tool names to learn the required arguments before calling execute_tool.',
inputSchema: learnToolsInputSchema,
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
const { toolNames, aspects } = parameters;
@@ -0,0 +1,138 @@
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
describe('compactMetadataOutput', () => {
it('should strip keys with null values when listed in stripWhenNullish', () => {
const record = {
id: '123',
name: 'test',
description: null,
icon: null,
label: 'Test',
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'icon'],
});
expect(result).toEqual({
id: '123',
name: 'test',
label: 'Test',
});
});
it('should strip keys with undefined values when listed in stripWhenNullish', () => {
const record = {
id: '123',
options: undefined,
settings: undefined,
label: 'Test',
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['options', 'settings'],
});
expect(result).toEqual({
id: '123',
label: 'Test',
});
});
it('should not strip keys with truthy values', () => {
const record = {
id: '123',
description: 'A description',
options: [{ label: 'A', value: 'A' }],
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'options'],
});
expect(result).toEqual({
id: '123',
description: 'A description',
options: [{ label: 'A', value: 'A' }],
});
});
it('should strip keys with false values when listed in stripWhenFalse', () => {
const record = {
id: '123',
isLabelSyncedWithName: false,
isUIReadOnly: false,
isActive: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
isActive: true,
});
});
it('should not strip keys with true values when listed in stripWhenFalse', () => {
const record = {
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
};
const result = compactMetadataOutput(record, {
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
isLabelSyncedWithName: true,
isUIReadOnly: true,
});
});
it('should apply both stripWhenNullish and stripWhenFalse together', () => {
const record = {
id: '123',
name: 'test',
description: null,
icon: 'IconStar',
isLabelSyncedWithName: false,
isUIReadOnly: true,
options: null,
};
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description', 'options'],
stripWhenFalse: ['isLabelSyncedWithName', 'isUIReadOnly'],
});
expect(result).toEqual({
id: '123',
name: 'test',
icon: 'IconStar',
isUIReadOnly: true,
});
});
it('should return a copy without modifying the original', () => {
const record = { id: '123', description: null };
const result = compactMetadataOutput(record, {
stripWhenNullish: ['description'],
});
expect(record).toEqual({ id: '123', description: null });
expect(result).toEqual({ id: '123' });
});
it('should handle empty config gracefully', () => {
const record = { id: '123', name: 'test' };
const result = compactMetadataOutput(record, {});
expect(result).toEqual({ id: '123', name: 'test' });
});
});
@@ -0,0 +1,208 @@
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const buildException = (
report: Record<string, unknown[]>,
): WorkspaceMigrationBuilderException => {
return new WorkspaceMigrationBuilderException({
status: 'fail' as const,
report: report as never,
});
};
describe('formatValidationErrors', () => {
it('should format a single error with its identifier', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'bad_field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Name must be camelCase (bad_field)',
);
});
it('should group repeated errors and list all identifiers', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'interno' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'retailer_name' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'order_date' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Name must be camelCase (interno, retailer_name, order_date)',
);
});
it('should handle multiple different errors across entities', () => {
const error = buildException({
fieldMetadata: [
{
errors: [
{ code: 'INVALID_NAME', message: 'Name must be camelCase' },
{ code: 'MISSING_TYPE', message: 'Type is required' },
],
flatEntityMinimalInformation: { name: 'bad_field' },
},
{
errors: [{ code: 'INVALID_NAME', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: 'another_bad' },
},
],
});
const result = formatValidationErrors(error);
expect(result).toContain(
'[fieldMetadata] Name must be camelCase (bad_field, another_bad)',
);
expect(result).toContain('[fieldMetadata] Type is required (bad_field)');
});
it('should fall back to code when message is missing', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'SNAKE_CASE_REQUIRED', message: '' }],
flatEntityMinimalInformation: { name: 'test_field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] SNAKE_CASE_REQUIRED (test_field)',
);
});
it('should use nameSingular as identifier for object metadata', () => {
const error = buildException({
objectMetadata: [
{
errors: [{ code: 'DUPLICATE', message: 'Object already exists' }],
flatEntityMinimalInformation: { nameSingular: 'invoice' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[objectMetadata] Object already exists (invoice)',
);
});
it('should use label as fallback identifier', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Something wrong' }],
flatEntityMinimalInformation: { label: 'My Field' },
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Something wrong (My Field)',
);
});
it('should handle failures without identifiers', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Unknown error' }],
flatEntityMinimalInformation: {},
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] Unknown error',
);
});
it('should handle failures without flatEntityMinimalInformation', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'No info' }],
},
],
});
expect(formatValidationErrors(error)).toBe(
'Validation errors:\n[fieldMetadata] No info',
);
});
it('should return error.message when report has no failures', () => {
const error = buildException({
fieldMetadata: [],
objectMetadata: [],
});
expect(formatValidationErrors(error)).toBe(
'Workspace migration builder failed',
);
});
it('should handle multiple entity types', () => {
const error = buildException({
fieldMetadata: [
{
errors: [{ code: 'ERR', message: 'Field error' }],
flatEntityMinimalInformation: { name: 'myField' },
},
],
objectMetadata: [
{
errors: [{ code: 'ERR', message: 'Object error' }],
flatEntityMinimalInformation: { nameSingular: 'myObject' },
},
],
});
const result = formatValidationErrors(error);
expect(result).toContain('[fieldMetadata] Field error (myField)');
expect(result).toContain('[objectMetadata] Object error (myObject)');
});
it('should skip entries with no errors array', () => {
const error = buildException({
fieldMetadata: [{ flatEntityMinimalInformation: { name: 'test' } }],
});
expect(formatValidationErrors(error)).toBe(
'Workspace migration builder failed',
);
});
it('should handle large batch with identical errors efficiently', () => {
const failures = Array.from({ length: 10 }, (_, i) => ({
errors: [{ code: 'INVALID', message: 'Name must be camelCase' }],
flatEntityMinimalInformation: { name: `field_${i}` },
}));
const error = buildException({ fieldMetadata: failures });
const result = formatValidationErrors(error);
const lines = result.split('\n');
expect(lines).toHaveLength(2);
expect(lines[1]).toContain('field_0');
expect(lines[1]).toContain('field_9');
});
});
@@ -0,0 +1,25 @@
type CompactConfig = {
stripWhenNullish?: string[];
stripWhenFalse?: string[];
};
export const compactMetadataOutput = (
metadata: Record<string, unknown>,
config: CompactConfig,
): Record<string, unknown> => {
const result = { ...metadata };
for (const key of config.stripWhenNullish ?? []) {
if (result[key] === null || result[key] === undefined) {
delete result[key];
}
}
for (const key of config.stripWhenFalse ?? []) {
if (result[key] === false) {
delete result[key];
}
}
return result;
};
@@ -1,28 +1,68 @@
import type { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
const getFailureIdentifier = (failure: {
flatEntityMinimalInformation?: Partial<Record<string, unknown>>;
}): string | undefined => {
const info = failure.flatEntityMinimalInformation;
if (!info) {
return undefined;
}
return (
(info.name as string | undefined) ??
(info.nameSingular as string | undefined) ??
(info.label as string | undefined) ??
(info.id as string | undefined)
);
};
export const formatValidationErrors = (
error: WorkspaceMigrationBuilderException,
): string => {
const report = error.failedWorkspaceMigrationBuildResult.report;
const errorMessages: string[] = [];
const grouped = new Map<string, string[]>();
for (const [entityType, failures] of Object.entries(report)) {
if (Array.isArray(failures) && failures.length > 0) {
for (const failure of failures) {
if (failure.errors && Array.isArray(failure.errors)) {
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
if (!Array.isArray(failures) || failures.length === 0) {
continue;
}
errorMessages.push(`[${entityType}] ${message}`);
}
for (const failure of failures) {
if (!failure.errors || !Array.isArray(failure.errors)) {
continue;
}
const identifier = getFailureIdentifier(failure);
for (const validationError of failure.errors) {
const message = validationError.message || validationError.code;
const key = `[${entityType}] ${message}`;
const existing = grouped.get(key) ?? [];
if (identifier) {
existing.push(identifier);
}
grouped.set(key, existing);
}
}
}
if (errorMessages.length === 0) {
if (grouped.size === 0) {
return error.message;
}
return `Validation errors:\n${errorMessages.join('\n')}`;
const lines: string[] = [];
for (const [message, identifiers] of grouped) {
if (identifiers.length > 1) {
lines.push(`${message} (${identifiers.join(', ')})`);
} else if (identifiers.length === 1) {
lines.push(`${message} (${identifiers[0]})`);
} else {
lines.push(message);
}
}
return `Validation errors:\n${lines.join('\n')}`;
};
@@ -2,6 +2,7 @@ import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'twenty-shared/ai';
import { toToolJsonSchema } from 'src/engine/core-modules/record-crud/utils/to-tool-json-schema.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
@@ -36,9 +37,8 @@ export const toolSetToDescriptors = (
let inputSchema: object;
try {
inputSchema = z.toJSONSchema(tool.inputSchema as z.ZodType);
inputSchema = toToolJsonSchema(tool.inputSchema as z.ZodType);
} catch {
// Fallback: schema is already JSON Schema or another format
inputSchema = (tool.inputSchema ?? {}) as object;
}