diff --git a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts index 4449779631..361fbc751b 100644 --- a/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts +++ b/packages/twenty-server/src/engine/api/mcp/utils/build-mcp-server-instructions.util.ts @@ -14,7 +14,7 @@ export const buildMcpServerInstructions = ( ``, `Meta-tools (always available):`, ` execute_tool(toolName, arguments) — execute any CRUD or action tool by name`, - ` learn_tools(toolNames) — fetch input schema for specific tools before calling them`, + ` learn_tools(toolNames) — fetch input schemas before calling tools; pass ALL needed tool names in one call, not one call per tool`, ` load_skills(skillNames) — load step-by-step instructions for complex tasks`, ``, ...(skillNames ? [`Available skills: ${skillNames}.`, ``] : []), @@ -26,6 +26,7 @@ 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 — keep that default for listing/inspecting; only set includeFullSystemObjects / includeFullSystemFields=true when you specifically need a system item's full configuration`, ` 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`, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts index 0a0322f910..736a6fdc2f 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/action-tool.provider.ts @@ -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 }, }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts index 6a487f3959..97c6c9391d 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/providers/database-tool.provider.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts index 3efdd975a1..4f314bb487 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts @@ -12,12 +12,16 @@ export type LearnToolsAspect = z.infer; 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; @@ -40,7 +44,7 @@ export const createLearnToolsTool = ( excludeTools?: Set, ) => ({ 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 => { const { toolNames, aspects } = parameters; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/compact-metadata-output.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/compact-metadata-output.util.spec.ts new file mode 100644 index 0000000000..74a9d2ba0b --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/compact-metadata-output.util.spec.ts @@ -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' }); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/format-validation-errors.util.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/format-validation-errors.util.spec.ts new file mode 100644 index 0000000000..f639a0bcf4 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/__tests__/format-validation-errors.util.spec.ts @@ -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, +): 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'); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/compact-metadata-output.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/compact-metadata-output.util.ts new file mode 100644 index 0000000000..4787e8d703 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/compact-metadata-output.util.ts @@ -0,0 +1,25 @@ +type CompactConfig = { + stripWhenNullish?: string[]; + stripWhenFalse?: string[]; +}; + +export const compactMetadataOutput = ( + metadata: Record, + config: CompactConfig, +): Record => { + 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; +}; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts index 097259664d..a841416198 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/format-validation-errors.util.ts @@ -1,28 +1,68 @@ import type { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +const getFailureIdentifier = (failure: { + flatEntityMinimalInformation?: Partial>; +}): 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(); 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')}`; }; diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts index 63eb3eaa14..b8c05ea10b 100644 --- a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts +++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util.ts @@ -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; } diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts index 7e0f2dfe8d..de215f9144 100644 --- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts +++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/constants/chat-system-prompts.const.ts @@ -9,7 +9,7 @@ For ANY non-trivial task, follow this order: 1. **Plan**: Identify what the user needs. Determine which domain is involved (workflows, dashboards, metadata, data, documents, etc.). 2. **Load the relevant skill FIRST**: Call \`load_skills\` to get detailed instructions, correct schemas, and parameter formats BEFORE doing anything else. Skills contain critical knowledge you don't have built-in — skipping this step leads to incorrect parameters and failed tool calls. -3. **Learn the required tools**: Call \`learn_tools\` to discover tool schemas and descriptions before using them. +3. **Learn the required tools**: Call \`learn_tools\` to discover tool schemas and descriptions before using them. Pass every tool you need in a single \`learn_tools\` call (\`toolNames\` is an array) — do not make one call per tool. 4. **Execute**: Call \`execute_tool\` to run the tools following the instructions from the skill. ⚠️ NEVER call a specialized tool (workflow, dashboard, metadata, etc.) without loading its matching skill first. The Available Skills section below lists all skills — look for the one that matches the user's task domain and load it. diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts index ccb072931b..ada2c3ffb6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory.ts @@ -4,104 +4,93 @@ 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', 'position', 'updatedBy']); + +const FIELD_STRIP_WHEN_NULLISH = [ + 'options', + 'settings', + 'defaultValue', + 'description', + 'icon', + 'deletedAt', +]; + +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( + "Keep false (default) for listing or inspecting fields — system fields then return as compact {id, name, type}, which is enough to know which fields exist and their types. Only set true when you specifically need a system field's full configuration (settings, defaultValue, relation targets).", + ), 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 +98,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 +106,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 +137,15 @@ export class FieldMetadataToolsFactory { return { get_field_metadata: { description: - 'Find fields metadata. Retrieve information about the fields of objects in the workspace data model.', + "Returns an array of fields. System fields are returned as compact {id, name, type} — enough to know which fields exist and their types. Keep includeFullSystemFields at its default (false); only set it true when you specifically need a system field's full configuration (settings, defaultValue, relation targets). Internal fields (searchVector, 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 +157,34 @@ export class FieldMetadataToolsFactory { }, paging: { limit: parameters.limit ?? 100 }, }); + + const compactedFields = ( + rawResults as unknown as Record[] + ) + .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 +211,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 +228,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 +255,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 +270,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 +280,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 +370,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<{ diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool.ts index d5cb31d4cb..bf66496521 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/create-navigation-menu-item.tool.ts @@ -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, }), diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/delete-navigation-menu-item.tool.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/delete-navigation-menu-item.tool.ts index cd2109141a..e92865352d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/delete-navigation-menu-item.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/delete-navigation-menu-item.tool.ts @@ -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< diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/list-navigation-menu-items.tool.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/list-navigation-menu-items.tool.ts index 92f9afa59f..39f1397ebd 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/list-navigation-menu-items.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/list-navigation-menu-items.tool.ts @@ -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.'), diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/update-navigation-menu-item.tool.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/update-navigation-menu-item.tool.ts index 7f553aae7d..6034e75277 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/update-navigation-menu-item.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/tools/update-navigation-menu-item.tool.ts @@ -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)'), }); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts index 41730e759c..e203282520 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory.ts @@ -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.', + "Keep false (default) for listing or locating objects — system objects then return as compact {id, nameSingular, namePlural}, which is enough to find an object and read its id. Only set true when you specifically need a system object's full configuration (e.g. building a relation to workspaceMember).", ), 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.', + "List object metadata as an array. System objects are returned as compact {id, nameSingular, namePlural} — enough to locate an object by name and read its id. Keep includeFullSystemObjects at its default (false); only set it true when you specifically need a system object's full configuration.", 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<{ diff --git a/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts index 9ac5938585..b0c8fbb981 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-field/tools/view-field-tools.factory.ts @@ -21,7 +21,6 @@ const GetViewFieldsInputSchema = z.object({ const CreateViewFieldInputSchema = z.object({ viewId: z.string().uuid().describe('The ID of the view to add the field to.'), fieldMetadataId: z - .string() .uuid() .describe( 'The ID of the field metadata to add. Use get_field_metadata to find available fields.', diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory.ts index 13be5ed1d9..75e6da8a67 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/tools/view-filter-tools.factory.ts @@ -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<{ diff --git a/packages/twenty-server/src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory.ts index 894476dfad..13444c75c8 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-sort/tools/view-sort-tools.factory.ts @@ -20,10 +20,9 @@ const GetViewSortsInputSchema = z.object({ const CreateViewSortInputSchema = z.object({ viewId: z.string().uuid().describe('ID of the view to add the sort to'), fieldMetadataId: z - .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 +81,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 +115,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<{ diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/tools/delete-webhook.tool.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/tools/delete-webhook.tool.ts index 24213117f6..547eb3d3a4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/tools/delete-webhook.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/tools/delete-webhook.tool.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/webhook/tools/update-webhook.tool.ts b/packages/twenty-server/src/engine/metadata-modules/webhook/tools/update-webhook.tool.ts index 275dbc8ef8..63ad285d84 100644 --- a/packages/twenty-server/src/engine/metadata-modules/webhook/tools/update-webhook.tool.ts +++ b/packages/twenty-server/src/engine/metadata-modules/webhook/tools/update-webhook.tool.ts @@ -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() diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts index 71277e9ef2..a7b91c1871 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/skill-metadata/create-standard-flat-skill-metadata.util.ts @@ -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,10 @@ 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 an array of objects. System objects (attachment, message, etc.) are returned as compact \`{id, nameSingular, namePlural}\`, which is enough to locate an object and read its id. Only pass \`includeFullSystemObjects: true\` when you specifically need a system object's full configuration (e.g. when creating relations to workspaceMember). +- **get_field_metadata** returns an array of fields. System fields are returned as compact \`{id, name, type}\`, which is enough to know which fields exist and their types. Only pass \`includeFullSystemFields: true\` when you specifically need a system field's full configuration (settings, defaultValue, relation targets). Internal fields (searchVector, position, updatedBy) are always excluded. Null properties are omitted from non-system fields. ## Field Types Available - **TEXT**: Simple text fields @@ -1141,7 +1145,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 +1218,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 +1261,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 +1340,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.). diff --git a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts index bbed4d0106..3a087785e5 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/add-dashboard-widget.tool.ts @@ -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. diff --git a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts index 153b1bbadd..8acf321a07 100644 --- a/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts +++ b/packages/twenty-server/src/modules/dashboard/tools/create-complete-dashboard.tool.ts @@ -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