Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edbdca614c | |||
| 70ada24a1d | |||
| 78ea9d8eeb | |||
| ce32c76ab9 | |||
| 92a9c3a4d4 | |||
| fa408c9126 | |||
| fba8d15f8b |
@@ -26,6 +26,8 @@ export const buildMcpServerInstructions = (
|
||||
` ACTION: http_request | send_email | draft_email | navigate_app | code_interpreter | search_help_center`,
|
||||
` WORKFLOW: create_complete_workflow | create/update/delete_workflow_version_step | activate/deactivate_workflow_version`,
|
||||
` METADATA: get/create/update/delete_object_metadata | get/create/update/delete_field_metadata`,
|
||||
` Both GET tools return system items as compact summaries by default; set includeFullSystemObjects / includeFullSystemFields=true for full payload`,
|
||||
` Both wrap results in {workspaceId, applicationId, ...}`,
|
||||
` VIEW: get_views | get_view_query_parameters | create/update/delete_view | manage view fields, filters, sorts`,
|
||||
` DASHBOARD: list_dashboards | get_dashboard | create_complete_dashboard | add/update/delete_dashboard_widget`,
|
||||
` WEBHOOK: list/create/update/delete_webhook`,
|
||||
|
||||
+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,
|
||||
|
||||
+3
-1
@@ -17,7 +17,9 @@ export const learnToolsInputSchema = z.object({
|
||||
.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>;
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
|
||||
+111
-106
@@ -4,104 +4,97 @@ import { type ToolSet } from 'ai';
|
||||
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { FieldMetadataService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata.service';
|
||||
import { fromFlatFieldMetadataToFieldMetadataDto } from 'src/engine/metadata-modules/flat-field-metadata/utils/from-flat-field-metadata-to-field-metadata-dto.util';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
const EXCLUDED_FIELD_NAMES = new Set([
|
||||
'searchVector',
|
||||
'deletedAt',
|
||||
'position',
|
||||
'updatedBy',
|
||||
]);
|
||||
|
||||
const FIELD_STRIP_WHEN_NULLISH = [
|
||||
'options',
|
||||
'settings',
|
||||
'defaultValue',
|
||||
'description',
|
||||
'icon',
|
||||
];
|
||||
|
||||
const FIELD_STRIP_WHEN_FALSE = ['isLabelSyncedWithName', 'isUIReadOnly'];
|
||||
|
||||
const GetFieldMetadataInputSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe(
|
||||
'Unique identifier for the field metadata. If provided, returns a single field.',
|
||||
),
|
||||
.describe('Field ID. Returns one field if set.'),
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Filter fields by object metadata ID.'),
|
||||
.describe('Filter by object ID.'),
|
||||
includeFullSystemFields: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Return full payload for system fields. Default: false — system fields are returned as compact {id, name, type}.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of fields to return.'),
|
||||
.describe('Max fields to return.'),
|
||||
});
|
||||
|
||||
const CreateFieldMetadataInputSchema = z.object({
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the object to add the field to'),
|
||||
type: z
|
||||
.nativeEnum(FieldMetadataType)
|
||||
.describe(
|
||||
'Field type (e.g., TEXT, NUMBER, BOOLEAN, DATE_TIME, RELATION, etc.)',
|
||||
),
|
||||
name: z.string().describe('Internal name of the field (camelCase)'),
|
||||
label: z.string().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isNullable: z.boolean().optional().describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z.unknown().optional().describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
objectMetadataId: z.string().uuid().describe('Target object ID'),
|
||||
type: z.nativeEnum(FieldMetadataType).describe('Field type'),
|
||||
name: z.string().describe('Field name (camelCase)'),
|
||||
label: z.string().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
isNullable: z.boolean().optional().describe('Nullable'),
|
||||
isUnique: z.boolean().optional().describe('Unique constraint'),
|
||||
defaultValue: z.unknown().optional().describe('Default value'),
|
||||
options: z.unknown().optional().describe('SELECT/MULTI_SELECT options'),
|
||||
settings: z.unknown().optional().describe('Field settings'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
isRemoteCreation: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether this is a remote field creation'),
|
||||
.describe('Sync label with name'),
|
||||
isRemoteCreation: z.boolean().optional().describe('Remote field creation'),
|
||||
relationCreationPayload: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Payload for creating relation fields'),
|
||||
.describe('Relation creation payload'),
|
||||
});
|
||||
|
||||
const UpdateFieldMetadataInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the field to update'),
|
||||
name: z.string().optional().describe('Internal name of the field'),
|
||||
label: z.string().optional().describe('Display label of the field'),
|
||||
description: z.string().optional().describe('Description of the field'),
|
||||
icon: z.string().optional().describe('Icon identifier for the field'),
|
||||
isActive: z.boolean().optional().describe('Whether the field is active'),
|
||||
isNullable: z.boolean().optional().describe('Whether the field can be null'),
|
||||
isUnique: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether the field value must be unique'),
|
||||
defaultValue: z.unknown().optional().describe('Default value for the field'),
|
||||
options: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Options for SELECT/MULTI_SELECT fields'),
|
||||
settings: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Additional settings for the field'),
|
||||
id: z.string().uuid().describe('Field ID'),
|
||||
name: z.string().optional().describe('Field name'),
|
||||
label: z.string().optional().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
isActive: z.boolean().optional().describe('Active state'),
|
||||
isNullable: z.boolean().optional().describe('Nullable'),
|
||||
isUnique: z.boolean().optional().describe('Unique constraint'),
|
||||
defaultValue: z.unknown().optional().describe('Default value'),
|
||||
options: z.unknown().optional().describe('SELECT/MULTI_SELECT options'),
|
||||
settings: z.unknown().optional().describe('Field settings'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
.describe('Sync label with name'),
|
||||
});
|
||||
|
||||
const DeleteFieldMetadataInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the field to delete'),
|
||||
id: z.string().uuid().describe('Field ID'),
|
||||
});
|
||||
|
||||
const CreateManyFieldMetadataInputSchema = z.object({
|
||||
@@ -109,7 +102,7 @@ const CreateManyFieldMetadataInputSchema = z.object({
|
||||
.array(CreateFieldMetadataInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of field metadata to create (1-20 items).'),
|
||||
.describe('Fields to create (max 20).'),
|
||||
});
|
||||
|
||||
const UpdateManyFieldMetadataInputSchema = z.object({
|
||||
@@ -117,49 +110,27 @@ const UpdateManyFieldMetadataInputSchema = z.object({
|
||||
.array(UpdateFieldMetadataInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of field metadata updates to apply (1-20 items).'),
|
||||
.describe('Fields to update (max 20).'),
|
||||
});
|
||||
|
||||
const CreateManyRelationFieldsInputSchema = z.object({
|
||||
relations: z
|
||||
.array(
|
||||
z.object({
|
||||
objectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the source object to add the relation field to'),
|
||||
name: z
|
||||
.string()
|
||||
.describe('Internal name of the relation field (camelCase)'),
|
||||
label: z.string().describe('Display label of the relation field'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Description of the relation field'),
|
||||
icon: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Icon identifier for the relation field'),
|
||||
type: z
|
||||
.nativeEnum(RelationType)
|
||||
.describe('Relation type: MANY_TO_ONE or ONE_TO_MANY'),
|
||||
targetObjectMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('ID of the target object this relation points to'),
|
||||
targetFieldLabel: z
|
||||
.string()
|
||||
.describe(
|
||||
'Display label for the inverse relation field on the target object',
|
||||
),
|
||||
targetFieldIcon: z
|
||||
.string()
|
||||
.describe('Icon for the inverse relation field (e.g. IconSomething)'),
|
||||
objectMetadataId: z.string().uuid().describe('Source object ID'),
|
||||
name: z.string().describe('Field name (camelCase)'),
|
||||
label: z.string().describe('Display label'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
type: z.nativeEnum(RelationType).describe('MANY_TO_ONE or ONE_TO_MANY'),
|
||||
targetObjectMetadataId: z.string().uuid().describe('Target object ID'),
|
||||
targetFieldLabel: z.string().describe('Inverse field label'),
|
||||
targetFieldIcon: z.string().describe('Inverse field icon'),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of relation fields to create (1-20 items).'),
|
||||
.describe('Relations to create (max 20).'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -170,14 +141,15 @@ export class FieldMetadataToolsFactory {
|
||||
return {
|
||||
get_field_metadata: {
|
||||
description:
|
||||
'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.',
|
||||
'Retrieve field metadata. Returns an array of fields. System fields are compact {id, name, type} by default; set includeFullSystemFields=true for full payload. Internal fields (searchVector, deletedAt, position, updatedBy) are excluded.',
|
||||
inputSchema: GetFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
id?: string;
|
||||
objectMetadataId?: string;
|
||||
includeFullSystemFields?: boolean;
|
||||
limit?: number;
|
||||
}) => {
|
||||
return this.fieldMetadataService.query({
|
||||
const rawResults = await this.fieldMetadataService.query({
|
||||
filter: {
|
||||
workspaceId: { eq: workspaceId },
|
||||
...(parameters.id ? { id: { eq: parameters.id } } : {}),
|
||||
@@ -189,11 +161,34 @@ export class FieldMetadataToolsFactory {
|
||||
},
|
||||
paging: { limit: parameters.limit ?? 100 },
|
||||
});
|
||||
|
||||
const compactedFields = (
|
||||
rawResults as unknown as Record<string, unknown>[]
|
||||
)
|
||||
.filter((field) => !EXCLUDED_FIELD_NAMES.has(field.name as string))
|
||||
.map((field) => {
|
||||
if (field.isSystem && !parameters.includeFullSystemFields) {
|
||||
return {
|
||||
id: field.id,
|
||||
name: field.name,
|
||||
type: field.type,
|
||||
};
|
||||
}
|
||||
|
||||
return compactMetadataOutput(
|
||||
{ ...field },
|
||||
{
|
||||
stripWhenNullish: FIELD_STRIP_WHEN_NULLISH,
|
||||
stripWhenFalse: FIELD_STRIP_WHEN_FALSE,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
return compactedFields;
|
||||
},
|
||||
},
|
||||
create_field_metadata: {
|
||||
description:
|
||||
'Create a new field metadata on an object. Specify the objectMetadataId and field properties.',
|
||||
description: 'Create a new field on an object.',
|
||||
inputSchema: CreateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
objectMetadataId: string;
|
||||
@@ -220,7 +215,13 @@ export class FieldMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
return {
|
||||
id: flatFieldMetadata.id,
|
||||
name: flatFieldMetadata.name,
|
||||
label: flatFieldMetadata.label,
|
||||
type: flatFieldMetadata.type,
|
||||
objectMetadataId: flatFieldMetadata.objectMetadataId,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -231,7 +232,7 @@ export class FieldMetadataToolsFactory {
|
||||
},
|
||||
update_field_metadata: {
|
||||
description:
|
||||
'Update an existing field metadata. Provide the field ID and the properties to update.',
|
||||
'Update a field. Provide field ID and properties to change.',
|
||||
inputSchema: UpdateFieldMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
id: string;
|
||||
@@ -258,7 +259,12 @@ export class FieldMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
return {
|
||||
id: flatFieldMetadata.id,
|
||||
name: flatFieldMetadata.name,
|
||||
label: flatFieldMetadata.label,
|
||||
type: flatFieldMetadata.type,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -268,7 +274,7 @@ export class FieldMetadataToolsFactory {
|
||||
},
|
||||
},
|
||||
delete_field_metadata: {
|
||||
description: 'Delete a field metadata by its ID.',
|
||||
description: 'Delete a field by ID.',
|
||||
inputSchema: DeleteFieldMetadataInputSchema,
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
@@ -278,7 +284,7 @@ export class FieldMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatFieldMetadataToFieldMetadataDto(flatFieldMetadata);
|
||||
return { id: flatFieldMetadata.id, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -368,8 +374,7 @@ export class FieldMetadataToolsFactory {
|
||||
},
|
||||
},
|
||||
create_many_relation_fields: {
|
||||
description:
|
||||
'Create multiple relation fields between objects at once. This is the recommended way to add relations after creating objects and non-relation fields. Each item specifies the source object, target object, relation type, and labels for both sides of the relation.',
|
||||
description: 'Create multiple relation fields between objects at once.',
|
||||
inputSchema: CreateManyRelationFieldsInputSchema,
|
||||
execute: async (parameters: {
|
||||
relations: Array<{
|
||||
|
||||
+1
-2
@@ -18,7 +18,6 @@ const commonOptionalFields = {
|
||||
.optional()
|
||||
.describe('Position among siblings; defaults to the end.'),
|
||||
folderId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Parent folder id, if the item should live inside a folder.'),
|
||||
@@ -84,7 +83,7 @@ const createNavigationMenuItemSchema = z.discriminatedUnion('type', [
|
||||
z.object({
|
||||
type: z.literal(NavigationMenuItemType.PAGE_LAYOUT),
|
||||
scope: navigationMenuItemScopeSchema,
|
||||
pageLayoutId: z.string().uuid().describe('Id of the page layout to pin'),
|
||||
pageLayoutId: z.string().describe('Id of the page layout to pin'),
|
||||
name: requiredNameField,
|
||||
...commonOptionalFields,
|
||||
}),
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/
|
||||
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
|
||||
|
||||
const deleteNavigationMenuItemSchema = z.object({
|
||||
id: z.string().uuid().describe('Id of the navigation menu item to delete'),
|
||||
id: z.uuid().describe('Id of the navigation menu item to delete'),
|
||||
});
|
||||
|
||||
type DeleteNavigationMenuItemParams = z.infer<
|
||||
|
||||
-1
@@ -16,7 +16,6 @@ const listNavigationMenuItemsSchema = z.object({
|
||||
"'workspace' = shared navigation, 'user' = current user's favorites, 'all' = both merged (default).",
|
||||
),
|
||||
folderId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Only return items inside this folder.'),
|
||||
|
||||
+1
-3
@@ -5,7 +5,7 @@ import { type NavigationMenuItemToolContext } from 'src/engine/metadata-modules/
|
||||
import { type NavigationMenuItemToolDependencies } from 'src/engine/metadata-modules/navigation-menu-item/tools/types/navigation-menu-item-tool-dependencies.type';
|
||||
|
||||
const updateNavigationMenuItemSchema = z.object({
|
||||
id: z.string().uuid().describe('Id of the navigation menu item to update'),
|
||||
id: z.uuid().describe('Id of the navigation menu item to update'),
|
||||
name: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -19,7 +19,6 @@ const updateNavigationMenuItemSchema = z.object({
|
||||
position: z.number().optional().describe('New position among siblings'),
|
||||
folderId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
@@ -32,7 +31,6 @@ const updateNavigationMenuItemSchema = z.object({
|
||||
.describe('New URL (only meaningful for LINK items)'),
|
||||
pageLayoutId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('New page layout id (only meaningful for PAGE_LAYOUT items)'),
|
||||
});
|
||||
|
||||
+80
-58
@@ -3,18 +3,33 @@ import { Injectable } from '@nestjs/common';
|
||||
import { type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { compactMetadataOutput } from 'src/engine/core-modules/tool-provider/utils/compact-metadata-output.util';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { fromFlatObjectMetadataToObjectMetadataDto } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-flat-object-metadata-to-object-metadata-dto.util';
|
||||
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
|
||||
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
||||
|
||||
const OBJECT_STRIP_WHEN_NULLISH = [
|
||||
'standardOverrides',
|
||||
'color',
|
||||
'duplicateCriteria',
|
||||
'shortcut',
|
||||
'imageIdentifierFieldMetadataId',
|
||||
'description',
|
||||
'icon',
|
||||
];
|
||||
|
||||
const GetObjectMetadataInputSchema = z.object({
|
||||
id: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('Object ID. Returns one object if set.'),
|
||||
includeFullSystemObjects: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Unique identifier for the object metadata. If provided, returns a single object.',
|
||||
'Return full payload for system objects. Default: false — system objects are returned as compact {id, nameSingular, namePlural}.',
|
||||
),
|
||||
limit: z
|
||||
.number()
|
||||
@@ -22,63 +37,52 @@ const GetObjectMetadataInputSchema = z.object({
|
||||
.min(1)
|
||||
.max(100)
|
||||
.default(100)
|
||||
.describe('Maximum number of objects to return.'),
|
||||
.describe('Max objects to return.'),
|
||||
});
|
||||
|
||||
const CreateObjectMetadataInputSchema = z.object({
|
||||
nameSingular: z
|
||||
.string()
|
||||
.describe('Singular name for the object (e.g., "company")'),
|
||||
namePlural: z
|
||||
.string()
|
||||
.describe('Plural name for the object (e.g., "companies")'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.describe('Display label in singular form (e.g., "Company")'),
|
||||
labelPlural: z
|
||||
.string()
|
||||
.describe('Display label in plural form (e.g., "Companies")'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
|
||||
isRemote: z.boolean().optional().describe('Whether this is a remote object'),
|
||||
nameSingular: z.string().describe('Singular name (e.g. "company")'),
|
||||
namePlural: z.string().describe('Plural name (e.g. "companies")'),
|
||||
labelSingular: z.string().describe('Singular label (e.g. "Company")'),
|
||||
labelPlural: z.string().describe('Plural label (e.g. "Companies")'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut'),
|
||||
isRemote: z.boolean().optional().describe('Remote object'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
.describe('Sync label with name'),
|
||||
});
|
||||
|
||||
const UpdateObjectMetadataInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the object to update'),
|
||||
labelSingular: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Display label in singular form'),
|
||||
labelPlural: z.string().optional().describe('Display label in plural form'),
|
||||
nameSingular: z.string().optional().describe('Singular name for the object'),
|
||||
namePlural: z.string().optional().describe('Plural name for the object'),
|
||||
description: z.string().optional().describe('Description of the object'),
|
||||
icon: z.string().optional().describe('Icon identifier for the object'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut for the object'),
|
||||
isActive: z.boolean().optional().describe('Whether the object is active'),
|
||||
id: z.uuid().describe('Object ID'),
|
||||
labelSingular: z.string().optional().describe('Singular label'),
|
||||
labelPlural: z.string().optional().describe('Plural label'),
|
||||
nameSingular: z.string().optional().describe('Singular name'),
|
||||
namePlural: z.string().optional().describe('Plural name'),
|
||||
description: z.string().optional().describe('Description'),
|
||||
icon: z.string().optional().describe('Icon name'),
|
||||
shortcut: z.string().optional().describe('Keyboard shortcut'),
|
||||
isActive: z.boolean().optional().describe('Active state'),
|
||||
labelIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as label identifier'),
|
||||
.describe('Label identifier field ID'),
|
||||
imageIdentifierFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.optional()
|
||||
.describe('ID of the field used as image identifier'),
|
||||
.describe('Image identifier field ID'),
|
||||
isLabelSyncedWithName: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether label should sync with name changes'),
|
||||
.describe('Sync label with name'),
|
||||
});
|
||||
|
||||
const DeleteObjectMetadataInputSchema = z.object({
|
||||
id: z.string().uuid().describe('ID of the object to delete'),
|
||||
id: z.string().uuid().describe('Object ID'),
|
||||
});
|
||||
|
||||
const CreateManyObjectMetadataInputSchema = z.object({
|
||||
@@ -86,7 +90,7 @@ const CreateManyObjectMetadataInputSchema = z.object({
|
||||
.array(CreateObjectMetadataInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of object metadata to create (1-20 items).'),
|
||||
.describe('Objects to create (max 20).'),
|
||||
});
|
||||
|
||||
const UpdateManyObjectMetadataInputSchema = z.object({
|
||||
@@ -94,7 +98,7 @@ const UpdateManyObjectMetadataInputSchema = z.object({
|
||||
.array(UpdateObjectMetadataInputSchema)
|
||||
.min(1)
|
||||
.max(20)
|
||||
.describe('Array of object metadata updates to apply (1-20 items).'),
|
||||
.describe('Objects to update (max 20).'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -105,9 +109,13 @@ export class ObjectMetadataToolsFactory {
|
||||
return {
|
||||
get_object_metadata: {
|
||||
description:
|
||||
'Find objects metadata. Retrieve information about the data model objects in the workspace.',
|
||||
'Retrieve object metadata. Returns an array of objects. System objects are compact {id, nameSingular, namePlural} by default; set includeFullSystemObjects=true for full payload.',
|
||||
inputSchema: GetObjectMetadataInputSchema,
|
||||
execute: async (parameters: { id?: string; limit?: number }) => {
|
||||
execute: async (parameters: {
|
||||
id?: string;
|
||||
includeFullSystemObjects?: boolean;
|
||||
limit?: number;
|
||||
}) => {
|
||||
const flatObjectMetadatas =
|
||||
await this.objectMetadataService.findManyWithinWorkspace(
|
||||
workspaceId,
|
||||
@@ -117,14 +125,27 @@ export class ObjectMetadataToolsFactory {
|
||||
},
|
||||
);
|
||||
|
||||
return flatObjectMetadatas.map((flatObjectMetadata) =>
|
||||
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata),
|
||||
);
|
||||
return flatObjectMetadatas.map((flatObjectMetadata) => {
|
||||
const dto =
|
||||
fromFlatObjectMetadataToObjectMetadataDto(flatObjectMetadata);
|
||||
|
||||
if (dto.isSystem && !parameters.includeFullSystemObjects) {
|
||||
return {
|
||||
id: dto.id,
|
||||
nameSingular: dto.nameSingular,
|
||||
namePlural: dto.namePlural,
|
||||
};
|
||||
}
|
||||
|
||||
return compactMetadataOutput(
|
||||
{ ...dto },
|
||||
{ stripWhenNullish: OBJECT_STRIP_WHEN_NULLISH },
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
create_object_metadata: {
|
||||
description:
|
||||
'Create a new object metadata in the workspace data model.',
|
||||
description: 'Create a new object in the workspace data model.',
|
||||
inputSchema: CreateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
nameSingular: string;
|
||||
@@ -146,9 +167,11 @@ export class ObjectMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(
|
||||
flatObjectMetadata,
|
||||
);
|
||||
return {
|
||||
id: flatObjectMetadata.id,
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
labelSingular: flatObjectMetadata.labelSingular,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -159,7 +182,7 @@ export class ObjectMetadataToolsFactory {
|
||||
},
|
||||
update_object_metadata: {
|
||||
description:
|
||||
'Update an existing object metadata. Provide the object ID and the fields to update.',
|
||||
'Update an object. Provide object ID and properties to change.',
|
||||
inputSchema: UpdateObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
id: string;
|
||||
@@ -184,9 +207,11 @@ export class ObjectMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(
|
||||
flatObjectMetadata,
|
||||
);
|
||||
return {
|
||||
id: flatObjectMetadata.id,
|
||||
nameSingular: flatObjectMetadata.nameSingular,
|
||||
labelSingular: flatObjectMetadata.labelSingular,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -196,8 +221,7 @@ export class ObjectMetadataToolsFactory {
|
||||
},
|
||||
},
|
||||
delete_object_metadata: {
|
||||
description:
|
||||
'Delete an object metadata by its ID. This will also delete all associated fields.',
|
||||
description: 'Delete an object by ID. Also deletes associated fields.',
|
||||
inputSchema: DeleteObjectMetadataInputSchema,
|
||||
execute: async (parameters: { id: string }) => {
|
||||
try {
|
||||
@@ -207,9 +231,7 @@ export class ObjectMetadataToolsFactory {
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return fromFlatObjectMetadataToObjectMetadataDto(
|
||||
flatObjectMetadata,
|
||||
);
|
||||
return { id: flatObjectMetadata.id, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof WorkspaceMigrationBuilderException) {
|
||||
throw new Error(formatValidationErrors(error));
|
||||
@@ -220,7 +242,7 @@ export class ObjectMetadataToolsFactory {
|
||||
},
|
||||
create_many_object_metadata: {
|
||||
description:
|
||||
'Create multiple object metadata at once in the workspace data model. More efficient than calling create_object_metadata multiple times. Each item follows the same schema as create_object_metadata.',
|
||||
'Create multiple objects at once. Batch version of create_object_metadata.',
|
||||
inputSchema: CreateManyObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
objects: Array<{
|
||||
@@ -258,7 +280,7 @@ export class ObjectMetadataToolsFactory {
|
||||
},
|
||||
update_many_object_metadata: {
|
||||
description:
|
||||
'Update multiple object metadata at once. More efficient than calling update_object_metadata multiple times. Each item must include the object ID and the properties to update.',
|
||||
'Update multiple objects at once. Batch version of update_object_metadata.',
|
||||
inputSchema: UpdateManyObjectMetadataInputSchema,
|
||||
execute: async (parameters: {
|
||||
objects: Array<{
|
||||
|
||||
+3
-3
@@ -26,7 +26,7 @@ const CreateViewFilterInputSchema = z.object({
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'ID of the field to filter on. Use list_object_metadata_items to find field IDs.',
|
||||
'ID of the field to filter on. Use get_field_metadata to find field IDs.',
|
||||
),
|
||||
operand: z
|
||||
.enum(VIEW_FILTER_OPERAND_OPTIONS)
|
||||
@@ -122,7 +122,7 @@ export class ViewFilterToolsFactory {
|
||||
return {
|
||||
create_view_filter: {
|
||||
description:
|
||||
'Add a filter to a view. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
'Add a filter to a view. Use get_field_metadata to get fieldMetadataId values.',
|
||||
inputSchema: CreateViewFilterInputSchema,
|
||||
execute: async (parameters: {
|
||||
viewId: string;
|
||||
@@ -161,7 +161,7 @@ export class ViewFilterToolsFactory {
|
||||
},
|
||||
create_many_view_filters: {
|
||||
description:
|
||||
'Add multiple filters to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
'Add multiple filters to a view in one call. Use get_field_metadata to get fieldMetadataId values.',
|
||||
inputSchema: CreateManyViewFiltersInputSchema,
|
||||
execute: async (parameters: {
|
||||
filters: Array<{
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@ const CreateViewSortInputSchema = z.object({
|
||||
.string()
|
||||
.uuid()
|
||||
.describe(
|
||||
'ID of the field to sort by. Use list_object_metadata_items to find field IDs.',
|
||||
'ID of the field to sort by. Use get_field_metadata to find field IDs.',
|
||||
),
|
||||
direction: z
|
||||
.enum(VIEW_SORT_DIRECTION_OPTIONS)
|
||||
@@ -82,7 +82,7 @@ export class ViewSortToolsFactory {
|
||||
return {
|
||||
create_view_sort: {
|
||||
description:
|
||||
'Add a sort to a view. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
'Add a sort to a view. Use get_field_metadata to get fieldMetadataId values.',
|
||||
inputSchema: CreateViewSortInputSchema,
|
||||
execute: async (parameters: {
|
||||
viewId: string;
|
||||
@@ -116,7 +116,7 @@ export class ViewSortToolsFactory {
|
||||
},
|
||||
create_many_view_sorts: {
|
||||
description:
|
||||
'Add multiple sorts to a view in one call. Use list_object_metadata_items to get fieldMetadataId values.',
|
||||
'Add multiple sorts to a view in one call. Use get_field_metadata to get fieldMetadataId values.',
|
||||
inputSchema: CreateManyViewSortsInputSchema,
|
||||
execute: async (parameters: {
|
||||
sorts: Array<{
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { type WebhookToolContext } from 'src/engine/metadata-modules/webhook/too
|
||||
import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhook/tools/types/webhook-tool-dependencies.type';
|
||||
|
||||
const deleteWebhookSchema = z.object({
|
||||
id: z.string().uuid().describe('The id of the webhook to delete'),
|
||||
id: z.uuid().describe('The id of the webhook to delete'),
|
||||
});
|
||||
|
||||
type DeleteWebhookParams = z.infer<typeof deleteWebhookSchema>;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { type WebhookToolDependencies } from 'src/engine/metadata-modules/webhoo
|
||||
import { compileWebhookOperations } from 'src/engine/metadata-modules/webhook/tools/utils/compile-webhook-operations.util';
|
||||
|
||||
const updateWebhookSchema = z.object({
|
||||
id: z.string().uuid().describe('The id of the webhook to update'),
|
||||
id: z.uuid().describe('The id of the webhook to update'),
|
||||
targetUrl: z
|
||||
.string()
|
||||
.url()
|
||||
|
||||
+15
-10
@@ -182,7 +182,7 @@ For the fields you will create, make sure to create a good variety of field type
|
||||
*Here are the steps to follow closely:*
|
||||
|
||||
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 get_object_metadata to see all available objects in the workspace
|
||||
- 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")
|
||||
@@ -201,7 +201,7 @@ STEP 2: Wait 3 seconds, for the backend side effects to be completed
|
||||
STEP 3: Create all NON-RELATION fields for ALL objects by batch with create_many_field_metadata.
|
||||
Do a separate batch call for each object.
|
||||
This includes:
|
||||
- New custom fields for the standard objects (Person, Company, Opportunity) — use their objectMetadataId from list_object_metadata_items
|
||||
- New custom fields for the standard objects (Person, Company, Opportunity) — use their objectMetadataId from get_object_metadata
|
||||
- All non-relation fields for the new custom objects
|
||||
DO NOT include relation fields in this step. Only create TEXT, NUMBER, BOOLEAN, DATE_TIME, SELECT, MULTI_SELECT, CURRENCY, etc.
|
||||
SELECT option values must be UPPER_SNAKE_CASE
|
||||
@@ -332,12 +332,12 @@ You help users create and manage dashboards with widgets.
|
||||
- list_dashboards, get_dashboard
|
||||
- create_complete_dashboard
|
||||
- add_dashboard_tab, add_dashboard_widget, update_dashboard_widget, delete_dashboard_widget
|
||||
- list_object_metadata_items (resolve object + field IDs)
|
||||
- get_object_metadata / get_field_metadata (resolve object + field IDs)
|
||||
|
||||
## Graph Widget Workflow
|
||||
|
||||
1. Ask what data the user wants to visualize.
|
||||
2. Call list_object_metadata_items and resolve objectMetadataId + field IDs.
|
||||
2. Call get_object_metadata and get_field_metadata to resolve objectMetadataId + field IDs.
|
||||
3. Always call get_dashboard before modifying widgets.
|
||||
4. Build the widget configuration using the rules below.
|
||||
5. Call add_dashboard_widget or update_dashboard_widget. Use activeTabId from context if available.
|
||||
@@ -358,7 +358,7 @@ You help users create and manage dashboards with widgets.
|
||||
- Relation to composite field: \`owner.name\` where "name" is FULL_NAME → subFieldName must be "name.firstName" or "name.lastName" (NOT just "name")
|
||||
- Relation + composite: \`company.address.addressCity\` → subFieldName "address.addressCity"
|
||||
- **Never omit subFieldName for relation fields** — grouping by ID is almost never useful
|
||||
- **IMPORTANT**: Check the target field's type from list_object_metadata_items. If it is composite (FULL_NAME, ADDRESS, CURRENCY, EMAILS, PHONES, LINKS), you MUST drill into a specific subfield using dot notation (e.g. "name.firstName", "address.addressCity", "emails.primaryEmail").
|
||||
- **IMPORTANT**: Check the target field's type from get_field_metadata. If it is composite (FULL_NAME, ADDRESS, CURRENCY, EMAILS, PHONES, LINKS), you MUST drill into a specific subfield using dot notation (e.g. "name.firstName", "address.addressCity", "emails.primaryEmail").
|
||||
|
||||
## User Language Notes
|
||||
|
||||
@@ -469,6 +469,11 @@ You help users manage their workspace data model by creating, updating, and orga
|
||||
- **Relations**: Links between objects (one-to-many, many-to-one)
|
||||
- **Labels vs Names**: Labels are for display, names are internal identifiers (camelCase)
|
||||
|
||||
## Tool Output Format
|
||||
|
||||
- **get_object_metadata** returns \`{workspaceId, applicationId, objects: [...]}\`. System objects (attachment, message, etc.) are returned as compact \`{id, nameSingular, namePlural}\`. Pass \`includeFullSystemObjects: true\` for the full payload (e.g. when creating relations to workspaceMember).
|
||||
- **get_field_metadata** returns \`{workspaceId, applicationId, fields: [...]}\`. System fields are returned as compact \`{id, name, type}\`. Pass \`includeFullSystemFields: true\` for the full payload. Internal fields (searchVector, deletedAt, position, updatedBy) are always excluded. Null properties are omitted from non-system fields.
|
||||
|
||||
## Field Types Available
|
||||
|
||||
- **TEXT**: Simple text fields
|
||||
@@ -1141,7 +1146,7 @@ You help users create and configure views to organize how they see their records
|
||||
- create_many_view_fields - Add visible columns to a view
|
||||
- update_many_view_fields - Update column configuration
|
||||
- get_view_fields - List columns in a view
|
||||
- list_object_metadata_items - Discover objects and their fields
|
||||
- get_object_metadata / get_field_metadata - Discover objects and their fields
|
||||
- navigate_app - Navigate to a view after creation
|
||||
|
||||
## Workflow
|
||||
@@ -1214,7 +1219,7 @@ You help users add filters and sorts to their views so they see the most relevan
|
||||
|
||||
- get_views - List existing views to find the one to modify
|
||||
- get_view_query_parameters - Check existing filters and sorts on a view
|
||||
- list_object_metadata_items - Discover fields and their types to build valid filters
|
||||
- get_field_metadata - Discover fields and their types to build valid filters
|
||||
- create_view_filter / create_many_view_filters - Add filters to a view
|
||||
- create_view_sort / create_many_view_sorts - Add sorts to a view
|
||||
- navigate_app - Navigate to the view to show results
|
||||
@@ -1257,7 +1262,7 @@ Filters can be grouped with logical operators:
|
||||
- "Show people from a specific company"
|
||||
- "Show recent records created in the last 30 days"
|
||||
|
||||
3. **Inspect the view**: Use get_view_query_parameters to see existing filters/sorts and list_object_metadata_items to discover available fields.
|
||||
3. **Inspect the view**: Use get_view_query_parameters to see existing filters/sorts and get_field_metadata to discover available fields.
|
||||
|
||||
4. **Build filters**: Based on the user's need, determine:
|
||||
- Which field(s) to filter on
|
||||
@@ -1336,12 +1341,12 @@ You help users archive custom objects from their workspace, such as objects crea
|
||||
|
||||
## Tools
|
||||
|
||||
- list_object_metadata_items - List all objects in the workspace to identify custom ones
|
||||
- get_object_metadata - List all objects in the workspace to identify custom ones
|
||||
- update_many_object_metadata - Archive custom objects by setting isActive to false
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **List all objects**: Use list_object_metadata_items to get the full list of objects in the workspace.
|
||||
1. **List all objects**: Use get_object_metadata to get the full list of objects in the workspace.
|
||||
|
||||
2. **Identify custom objects**: Filter the results to find objects where isCustom is true. These are the objects that were created by users or by the dev seed, as opposed to standard built-in objects (Company, Person, Opportunity, Task, Note, etc.).
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export const createAddDashboardWidgetTool = (
|
||||
description: `Add a widget to an existing dashboard tab.
|
||||
|
||||
Use get_dashboard first to get pageLayoutTabId and existing widget positions.
|
||||
Use list_object_metadata_items to get objectMetadataId and field IDs for GRAPH widgets.
|
||||
Use get_object_metadata and get_field_metadata to get objectMetadataId and field IDs for GRAPH widgets.
|
||||
|
||||
For RECORD_TABLE widgets: create a dedicated view first with create_view (type TABLE), then pass its viewId in configuration. Never reuse an existing record index view.
|
||||
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ export const createCreateCompleteDashboardTool = (
|
||||
name: 'create_complete_dashboard' as const,
|
||||
description: `Create a dashboard with layout, tab, and widgets.
|
||||
|
||||
IMPORTANT: Before creating GRAPH widgets, you MUST use list_object_metadata_items to get valid objectMetadataId and field IDs.
|
||||
IMPORTANT: Before creating GRAPH widgets, you MUST use get_object_metadata and get_field_metadata to get valid objectMetadataId and field IDs.
|
||||
|
||||
GRID SYSTEM:
|
||||
- 12 columns (0-11), rows start at 0
|
||||
|
||||
Reference in New Issue
Block a user