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:
+2
-1
@@ -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 },
|
||||
};
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
+7
-3
@@ -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;
|
||||
|
||||
+138
@@ -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' });
|
||||
});
|
||||
});
|
||||
+208
@@ -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');
|
||||
});
|
||||
});
|
||||
+25
@@ -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;
|
||||
};
|
||||
+50
-10
@@ -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
-2
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user