feat: improve AI chat - system prompt, tool output, context window display (#17769)

⚠️ **AI-generated PR — not ready for review** ⚠️

cc @FelixMalfait

---

## Changes

### System prompt improvements
- Explicit skill-before-tools workflow to prevent the model from calling
tools without loading the matching skill first
- Data efficiency guidance (default small limits, use filters)
- Pluralized `load_skill` → `load_skills` for consistency with
`load_tools`

### Token usage reduction
- Output serialization layer: strips null/undefined/empty values from
tool results
- Lowered default `find_*` limit from 100 → 10, max from 1000 → 100

### System object tool generation
- System objects (calendar events, messages, etc.) now generate AI tools
- Only workflow-related and favorite-related objects are excluded

### Context window display fix
- **Bug**: UI compared cumulative tokens (sum of all turns) against
single-request context window → showed 100% after a few turns
- **Fix**: Track `conversationSize` (last step's `inputTokens`) which
represents the actual conversation history size sent to the model
- New `conversationSize` column on thread entity with migration

### Workspace AI instructions
- Support for custom workspace-level AI instructions

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-02-09 14:26:02 +01:00
committed by GitHub
co-authored by claude[bot] <41898282+claude[bot]@users.noreply.github.com>
parent 6c7c389785
commit 3216b634a3
105 changed files with 3245 additions and 1714 deletions
@@ -23,6 +23,10 @@ import { BillingSubscriptionService } from 'src/engine/core-modules/billing/serv
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { BillingService } from 'src/engine/core-modules/billing/services/billing.service';
import { formatBillingDatabaseProductToGraphqlDTO } from 'src/engine/core-modules/billing/utils/format-database-product-to-graphql-dto.util';
import {
INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
toDisplayCredits,
} from 'src/engine/core-modules/billing/utils/to-display-credits.util';
import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter';
import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe';
import { type UserEntity } from 'src/engine/core-modules/user/user.entity';
@@ -298,7 +302,17 @@ export class BillingResolver {
async getMeteredProductsUsage(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<BillingMeteredProductUsageOutput[]> {
return await this.billingUsageService.getMeteredProductsUsage(workspace);
const usageData =
await this.billingUsageService.getMeteredProductsUsage(workspace);
return usageData.map((item) => ({
...item,
usedCredits: toDisplayCredits(item.usedCredits),
grantedCredits: toDisplayCredits(item.grantedCredits),
rolloverCredits: toDisplayCredits(item.rolloverCredits),
totalGrantedCredits: toDisplayCredits(item.totalGrantedCredits),
unitPriceCents: item.unitPriceCents * INTERNAL_CREDITS_PER_DISPLAY_CREDIT,
}));
}
@Mutation(() => BillingUpdateOutput)
@@ -1,4 +1,4 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { Field, Float, ObjectType } from '@nestjs/graphql';
import { BillingProductKey } from 'src/engine/core-modules/billing/enums/billing-product-key.enum';
@@ -13,18 +13,18 @@ export class BillingMeteredProductUsageOutput {
@Field(() => Date)
periodEnd: Date;
@Field(() => Number)
@Field(() => Float)
usedCredits: number;
@Field(() => Number)
@Field(() => Float)
grantedCredits: number;
@Field(() => Number)
@Field(() => Float)
rolloverCredits: number;
@Field(() => Number)
@Field(() => Float)
totalGrantedCredits: number;
@Field(() => Number)
@Field(() => Float)
unitPriceCents: number;
}
@@ -0,0 +1,9 @@
// Internal credits use micro-precision: $1 = 1,000,000 internal credits
// Display credits are 1000x coarser: $1 = 1,000 display credits
// This mirrors the "micro" pattern in payment systems (e.g. microdollars → dollars)
export const INTERNAL_CREDITS_PER_DISPLAY_CREDIT = 1000;
// Converts internal (high-precision) credits to user-facing display credits.
// Rounds to 1 decimal place for clean display (e.g. 7500 → 7.5).
export const toDisplayCredits = (internalCredits: number): number =>
Math.round((internalCredits / INTERNAL_CREDITS_PER_DISPLAY_CREDIT) * 10) / 10;
@@ -3,9 +3,11 @@ import { Module } from '@nestjs/common';
import { CoreCommonApiModule } from 'src/engine/api/common/core-common-api.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { UpsertRecordService } from 'src/engine/core-modules/record-crud/services/upsert-record.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@@ -23,14 +25,18 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
providers: [
CommonApiContextBuilderService,
CreateRecordService,
CreateManyRecordsService,
UpdateRecordService,
UpdateManyRecordsService,
DeleteRecordService,
FindRecordsService,
UpsertRecordService,
],
exports: [
CreateRecordService,
CreateManyRecordsService,
UpdateRecordService,
UpdateManyRecordsService,
DeleteRecordService,
FindRecordsService,
UpsertRecordService,
@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { FieldActorSource } from 'twenty-shared/types';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonCreateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-create-many-query-runner/common-create-many-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type CreateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/create-many-records-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@Injectable()
export class CreateManyRecordsService {
private readonly logger = new Logger(CreateManyRecordsService.name);
constructor(
private readonly commonCreateManyRunner: CommonCreateManyQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: CreateManyRecordsParams): Promise<ToolOutput> {
const { objectName, objectRecords, authContext } = params;
try {
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
objectName,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to create: Object cannot be created by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const actorMetadata = params.createdBy ?? {
source: FieldActorSource.WORKFLOW,
name: 'Workflow',
};
const cleanedRecords = objectRecords.map((record) => ({
...removeUndefinedFromRecord(record),
createdBy: actorMetadata,
}));
const createdRecords = await this.commonCreateManyRunner.execute(
{
data: cleanedRecords,
selectedFields,
},
queryRunnerContext,
);
this.logger.log(
`Created ${createdRecords.length} records in ${objectName}`,
);
return {
success: true,
message: `Created ${createdRecords.length} records in ${objectName}`,
result: params.slimResponse
? createdRecords.map((record) => ({ id: record.id }))
: createdRecords,
recordReferences: createdRecords.map((record) => ({
objectNameSingular: objectName,
recordId: record.id,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
})),
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
success: false,
message: `Failed to create records in ${objectName}`,
error: error.message,
};
}
this.logger.error(`Failed to create records: ${error}`);
return {
success: false,
message: `Failed to create records in ${objectName}`,
error:
error instanceof Error ? error.message : 'Failed to create records',
};
}
}
}
@@ -74,7 +74,7 @@ export class CreateRecordService {
return {
success: true,
message: `Record created successfully in ${objectName}`,
result: createdRecord,
result: params.slimResponse ? { id: createdRecord.id } : createdRecord,
recordReferences: [
{
objectNameSingular: objectName,
@@ -0,0 +1,97 @@
import { Injectable, Logger } from '@nestjs/common';
import { canObjectBeManagedByWorkflow } from 'twenty-shared/workflow';
import { CommonUpdateManyQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-update-many-query-runner.service';
import {
RecordCrudException,
RecordCrudExceptionCode,
} from 'src/engine/core-modules/record-crud/exceptions/record-crud.exception';
import { CommonApiContextBuilderService } from 'src/engine/core-modules/record-crud/services/common-api-context-builder.service';
import { type UpdateManyRecordsParams } from 'src/engine/core-modules/record-crud/types/update-many-records-params.type';
import { getRecordDisplayName } from 'src/engine/core-modules/record-crud/utils/get-record-display-name.util';
import { removeUndefinedFromRecord } from 'src/engine/core-modules/record-crud/utils/remove-undefined-from-record.util';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@Injectable()
export class UpdateManyRecordsService {
private readonly logger = new Logger(UpdateManyRecordsService.name);
constructor(
private readonly commonUpdateManyRunner: CommonUpdateManyQueryRunnerService,
private readonly commonApiContextBuilder: CommonApiContextBuilderService,
) {}
async execute(params: UpdateManyRecordsParams): Promise<ToolOutput> {
const { objectName, filter, data, authContext } = params;
try {
const {
queryRunnerContext,
selectedFields,
flatObjectMetadata,
flatFieldMetadataMaps,
} = await this.commonApiContextBuilder.build({
authContext,
objectName,
});
if (
!canObjectBeManagedByWorkflow({
nameSingular: flatObjectMetadata.nameSingular,
isSystem: flatObjectMetadata.isSystem,
})
) {
throw new RecordCrudException(
'Failed to update: Object cannot be updated by workflow',
RecordCrudExceptionCode.INVALID_REQUEST,
);
}
const cleanedData = removeUndefinedFromRecord(data);
const updatedRecords = await this.commonUpdateManyRunner.execute(
{ filter, data: cleanedData, selectedFields },
queryRunnerContext,
);
this.logger.log(
`Updated ${updatedRecords.length} records in ${objectName}`,
);
return {
success: true,
message: `Updated ${updatedRecords.length} records in ${objectName}`,
result: params.slimResponse
? updatedRecords.map((record) => ({ id: record.id }))
: updatedRecords,
recordReferences: updatedRecords.map((record) => ({
objectNameSingular: objectName,
recordId: record.id,
displayName: getRecordDisplayName(
record,
flatObjectMetadata,
flatFieldMetadataMaps,
),
})),
};
} catch (error) {
if (error instanceof RecordCrudException) {
return {
success: false,
message: `Failed to update records in ${objectName}`,
error: error.message,
};
}
this.logger.error(`Failed to update records: ${error}`);
return {
success: false,
message: `Failed to update records in ${objectName}`,
error:
error instanceof Error ? error.message : 'Failed to update records',
};
}
}
}
@@ -103,7 +103,7 @@ export class UpdateRecordService {
return {
success: true,
message: `Record updated successfully in ${objectName}`,
result: updatedRecord,
result: params.slimResponse ? { id: objectRecordId } : updatedRecord,
recordReferences: [
{
objectNameSingular: objectName,
@@ -1,156 +0,0 @@
import { type ToolSet } from 'ai';
import { type CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { type DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { type FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { type UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import {
type ObjectWithPermission,
type ToolGeneratorContext,
} from 'src/engine/core-modules/tool-generator/types/tool-generator.types';
// Dependencies required by the direct record tools factory
export type DirectRecordToolsDeps = {
createRecordService: CreateRecordService;
updateRecordService: UpdateRecordService;
deleteRecordService: DeleteRecordService;
findRecordsService: FindRecordsService;
};
export const createDirectRecordToolsFactory = (deps: DirectRecordToolsDeps) => {
return (
{
objectMetadata,
restrictedFields,
canCreate,
canRead,
canUpdate,
canDelete,
}: ObjectWithPermission,
context: ToolGeneratorContext,
): ToolSet => {
const tools: ToolSet = {};
// Skip generating tools if no auth context is provided
if (!context.authContext) {
return tools;
}
// Capture authContext in a constant for use in async callbacks
const authContext = context.authContext;
if (canRead) {
tools[`find_${objectMetadata.namePlural}`] = {
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. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
inputSchema: generateFindToolInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const {
loadingMessage: _,
limit,
offset,
orderBy,
...filter
} = parameters;
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter,
orderBy,
limit,
offset,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
tools[`find_one_${objectMetadata.nameSingular}`] = {
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
inputSchema: FindOneToolInputSchema,
execute: async (parameters) => {
return deps.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.id } },
limit: 1,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canCreate) {
tools[`create_${objectMetadata.nameSingular}`] = {
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.`,
inputSchema: generateCreateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { loadingMessage: _, ...objectRecord } = parameters;
return deps.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
});
},
};
}
if (canUpdate) {
tools[`update_${objectMetadata.nameSingular}`] = {
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.`,
inputSchema: generateUpdateRecordInputSchema(
objectMetadata,
restrictedFields,
),
execute: async (parameters) => {
const { loadingMessage: _, id, ...allFields } = parameters;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return deps.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
},
};
}
if (canDelete) {
tools[`soft_delete_${objectMetadata.nameSingular}`] = {
description: `Soft delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record remains in the database but is hidden from normal queries. This is reversible and preserves all data. Use this for temporary removal.`,
inputSchema: SoftDeleteToolInputSchema,
execute: async (parameters) => {
return deps.deleteRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: parameters.id,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
},
};
}
return tools;
};
};
@@ -0,0 +1,14 @@
import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type CreateManyRecordsParams = {
objectName: string;
objectRecords: ObjectRecordProperties[];
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
createdBy?: ActorMetadata;
slimResponse?: boolean;
};
@@ -4,4 +4,5 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
export type RecordCrudExecutionContext = {
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
slimResponse?: boolean;
};
@@ -0,0 +1,14 @@
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ObjectRecordProperties } from 'src/engine/core-modules/record-crud/types/object-record-properties.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
export type UpdateManyRecordsParams = {
objectName: string;
filter: Partial<ObjectRecordFilter>;
data: ObjectRecordProperties;
authContext: WorkspaceAuthContext;
rolePermissionConfig?: RolePermissionConfig;
slimResponse?: boolean;
};
@@ -0,0 +1,26 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
export const generateCreateManyRecordInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const recordSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
);
return z.object({
records: z
.array(recordSchema)
.min(1)
.max(20)
.describe(
'Array of records to create. Each record should contain the required fields. Maximum 20 records per call.',
),
});
};
@@ -0,0 +1,31 @@
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
import { generateRecordPropertiesZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-properties.zod-schema';
export const generateUpdateManyRecordInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const { filterSchema } = generateRecordFilterSchema(
objectMetadata,
restrictedFields,
);
const dataSchema = generateRecordPropertiesZodSchema(
objectMetadata,
false,
restrictedFields,
).partial();
return z.object({
filter: filterSchema.describe(
'Filter to select which records to update. Supports field-level filters and logical operators (or, and, not). WARNING: A broad filter may update many records at once. Always verify the filter scope with a find query first.',
),
data: dataSchema.describe(
'The field values to apply to all matching records. Only include fields you want to change.',
),
});
};
@@ -0,0 +1,7 @@
import { z } from 'zod';
export const DeleteToolInputSchema = z.object({
id: z.string().uuid().describe('The unique UUID of the record to delete'),
});
export type DeleteToolInput = z.infer<typeof DeleteToolInputSchema>;
@@ -1,64 +1,17 @@
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { type RestrictedFieldsPermissions } from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { ObjectRecordOrderBySchema } from 'src/engine/core-modules/record-crud/zod-schemas/order-by.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
export const generateFindToolInputSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
) => {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const filterSchema = generateFieldFilterZodSchema(field);
if (!filterSchema) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
filterSchema;
});
// Create the base filter schema with field-level filters + logical operators
// This matches the RecordGqlOperationFilter format used by the frontend
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
const { filterShape, filterSchema } = generateRecordFilterSchema(
objectMetadata,
restrictedFields,
);
return z.object({
@@ -66,9 +19,11 @@ export const generateFindToolInputSchema = (
.number()
.int()
.positive()
.max(1000)
.default(100)
.describe('Maximum number of records to return (default: 100)'),
.max(100)
.default(10)
.describe(
'Maximum number of records to return (default: 10, max: 100). Start small and increase only if needed.',
),
offset: z
.number()
.int()
@@ -0,0 +1,67 @@
import {
FieldMetadataType,
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
// Builds the per-field filter shape and full recursive filter schema
// for a given object metadata, reusable across find and updateMany tools
export const generateRecordFilterSchema = (
objectMetadata: ObjectMetadataForToolSchema,
restrictedFields?: RestrictedFieldsPermissions,
): {
filterShape: Record<string, z.ZodTypeAny>;
filterSchema: z.ZodTypeAny;
} => {
const filterShape: Record<string, z.ZodTypeAny> = {};
objectMetadata.fields.forEach((field) => {
if (shouldExcludeFieldFromAgentToolSchema(field)) {
return;
}
if (restrictedFields?.[field.id]?.canRead === false) {
return;
}
const fieldFilter = generateFieldFilterZodSchema(field);
if (!fieldFilter) {
return;
}
const isManyToOneRelationField =
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE;
filterShape[isManyToOneRelationField ? `${field.name}Id` : field.name] =
fieldFilter;
});
const filterSchema: z.ZodTypeAny = z.lazy(() =>
z
.object({
...filterShape,
or: z
.array(filterSchema)
.optional()
.describe('OR condition - matches if ANY of the filters match'),
and: z
.array(filterSchema)
.optional()
.describe('AND condition - matches if ALL filters match'),
not: filterSchema
.optional()
.describe('NOT condition - matches if the filter does NOT match'),
})
.partial(),
);
return { filterShape, filterSchema };
};
@@ -23,6 +23,8 @@ const isFieldAvailable = (field: FlatFieldMetadata, forResponse: boolean) => {
case 'createdAt':
case 'updatedAt':
case 'deletedAt':
case 'createdBy':
case 'updatedBy':
return false;
default:
return true;
@@ -253,7 +255,11 @@ export const generateRecordPropertiesZodSchema = (
break;
}
if (field.description) {
if (field.name === 'position') {
fieldSchema = fieldSchema.describe(
'Leave empty to place at the top of the list (recommended).',
);
} else if (field.description) {
fieldSchema = fieldSchema.describe(field.description);
}
@@ -1,10 +0,0 @@
import { z } from 'zod';
export const SoftDeleteToolInputSchema = z.object({
id: z
.string()
.uuid()
.describe('The unique UUID of the record to soft delete'),
});
export type SoftDeleteToolInput = z.infer<typeof SoftDeleteToolInputSchema>;
@@ -0,0 +1 @@
export const COMMON_PRELOAD_TOOLS: string[] = ['search_help_center'];
@@ -4,7 +4,7 @@ import { type ActorMetadata } from 'twenty-shared/types';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolType } from 'src/engine/core-modules/tool/enums/tool-type.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -27,7 +27,7 @@ export type ToolProviderContext = {
// Options for tool retrieval
export type ToolRetrievalOptions = {
categories?: ToolCategory[];
excludeTools?: ToolType[];
excludeTools?: string[];
wrapWithErrorContext?: boolean;
};
@@ -36,5 +36,15 @@ export interface ToolProvider {
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateDescriptors(context: ToolProviderContext): Promise<ToolDescriptor[]>;
}
// NativeModelToolProvider is special: SDK-native tools are opaque and not
// serializable. It keeps the old generateTools() contract.
export interface NativeToolProvider {
readonly category: ToolCategory;
isAvailable(context: ToolProviderContext): Promise<boolean>;
generateTools(context: ToolProviderContext): Promise<ToolSet>;
}
@@ -0,0 +1,153 @@
import { stripEmptyValues } from 'src/engine/core-modules/tool-provider/output-serialization/strip-empty-values.util';
describe('stripEmptyValues', () => {
it('should remove null values', () => {
expect(stripEmptyValues({ a: 1, b: null })).toEqual({ a: 1 });
});
it('should remove undefined values', () => {
expect(stripEmptyValues({ a: 1, b: undefined })).toEqual({ a: 1 });
});
it('should remove empty strings', () => {
expect(stripEmptyValues({ a: 'hello', b: '' })).toEqual({ a: 'hello' });
});
it('should remove empty objects', () => {
expect(stripEmptyValues({ a: 1, b: {} })).toEqual({ a: 1 });
});
it('should remove empty arrays', () => {
expect(stripEmptyValues({ a: 1, b: [] })).toEqual({ a: 1 });
});
it('should preserve non-empty values', () => {
expect(stripEmptyValues({ a: 0, b: false })).toEqual({ a: 0, b: false });
});
it('should recursively strip nested objects', () => {
const input = {
name: 'Acme',
address: {
city: null,
street: null,
state: null,
country: 'US',
},
links: {
primaryLinkUrl: '',
primaryLinkLabel: '',
secondaryLinks: [],
},
};
expect(stripEmptyValues(input)).toEqual({
name: 'Acme',
address: { country: 'US' },
});
});
it('should remove deeply nested empty objects', () => {
const input = {
name: 'Test',
nested: {
deep: {
empty: null,
alsoEmpty: '',
},
},
};
expect(stripEmptyValues(input)).toEqual({ name: 'Test' });
});
it('should strip empty values from arrays of objects', () => {
const input = {
records: [
{ id: '1', name: 'Acme', website: null, industry: '' },
{ id: '2', name: 'Beta', website: 'beta.com', industry: null },
],
};
expect(stripEmptyValues(input)).toEqual({
records: [
{ id: '1', name: 'Acme' },
{ id: '2', name: 'Beta', website: 'beta.com' },
],
});
});
it('should return undefined for entirely empty input', () => {
expect(stripEmptyValues({ a: null, b: '', c: {} })).toBeUndefined();
});
it('should handle a realistic tool output', () => {
const toolOutput = {
success: true,
message: 'Found 2 company records',
result: {
records: [
{
id: 'abc-123',
name: 'Acme Corp',
employees: 500,
industry: 'Technology',
website: null,
address: {
city: null,
street: null,
state: null,
country: null,
},
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
deletedAt: null,
},
],
count: 1,
},
recordReferences: [
{
objectNameSingular: 'company',
recordId: 'abc-123',
displayName: 'Acme Corp',
},
],
};
expect(stripEmptyValues(toolOutput)).toEqual({
success: true,
message: 'Found 2 company records',
result: {
records: [
{
id: 'abc-123',
name: 'Acme Corp',
employees: 500,
industry: 'Technology',
createdAt: '2024-01-01',
updatedAt: '2024-01-02',
},
],
count: 1,
},
recordReferences: [
{
objectNameSingular: 'company',
recordId: 'abc-123',
displayName: 'Acme Corp',
},
],
});
});
it('should handle primitive values', () => {
expect(stripEmptyValues(42)).toBe(42);
expect(stripEmptyValues('hello')).toBe('hello');
expect(stripEmptyValues(true)).toBe(true);
expect(stripEmptyValues(false)).toBe(false);
expect(stripEmptyValues(null)).toBeUndefined();
expect(stripEmptyValues(undefined)).toBeUndefined();
expect(stripEmptyValues('')).toBeUndefined();
});
});
@@ -0,0 +1,13 @@
import { stripEmptyValues } from './strip-empty-values.util';
// Compacts a tool output by stripping empty values and flattening
// the result structure for efficient LLM token consumption.
// This is applied as a post-processing step on all tool results
// before they are returned to the AI model.
export const compactToolOutput = (output: unknown): unknown => {
if (!output || typeof output !== 'object') {
return output;
}
return stripEmptyValues(output);
};
@@ -0,0 +1,32 @@
// Recursively strips null, undefined, empty strings, empty objects,
// and empty arrays from a value. Returns undefined if the entire
// value is empty so the caller can decide whether to include it.
export const stripEmptyValues = (value: unknown): unknown => {
if (value === null || value === undefined || value === '') {
return undefined;
}
if (Array.isArray(value)) {
const cleaned = value
.map(stripEmptyValues)
.filter((item) => item !== undefined);
return cleaned.length > 0 ? cleaned : undefined;
}
if (typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
const stripped = stripEmptyValues(val);
if (stripped !== undefined) {
result[key] = stripped;
}
}
return Object.keys(result).length > 0 ? result : undefined;
}
return value;
};
@@ -0,0 +1,34 @@
import { type ToolSet } from 'ai';
import { compactToolOutput } from './compact-tool-output.util';
// Wraps every tool's execute function with output serialization.
// The wrapper intercepts the raw tool result and applies compaction
// (strip nulls/empty, flatten) before the AI SDK serializes it
// into the conversation context.
//
// This is a composable utility — it can be chained with other
// wrappers like wrapToolsWithErrorContext.
export const wrapToolsWithOutputSerialization = (tools: ToolSet): ToolSet => {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
const result = await originalExecute(...args);
return compactToolOutput(result);
},
};
}
return wrappedTools;
};
@@ -1,8 +1,7 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import { type ZodObject, type ZodRawShape } from 'zod';
import { z } from 'zod';
import {
type ToolProvider,
@@ -10,47 +9,64 @@ import {
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import {
type StaticToolHandler,
ToolExecutorService,
} from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { CodeInterpreterTool } from 'src/engine/core-modules/tool/tools/code-interpreter-tool/code-interpreter-tool';
import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import {
type Tool,
type ToolExecutionContext,
} from 'src/engine/core-modules/tool/types/tool.type';
import {
stripLoadingMessage,
wrapSchemaForExecution,
} from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type Tool } from 'src/engine/core-modules/tool/types/tool.type';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class ActionToolProvider implements ToolProvider {
readonly category = ToolCategory.ACTION;
private readonly toolMap: Map<string, Tool>;
constructor(
private readonly httpTool: HttpTool,
private readonly sendEmailTool: SendEmailTool,
private readonly searchHelpCenterTool: SearchHelpCenterTool,
private readonly codeInterpreterTool: CodeInterpreterTool,
private readonly permissionsService: PermissionsService,
) {}
private readonly toolExecutorService: ToolExecutorService,
) {
this.toolMap = new Map<string, Tool>([
['http_request', this.httpTool],
['send_email', this.sendEmailTool],
['search_help_center', this.searchHelpCenterTool],
['code_interpreter', this.codeInterpreterTool],
]);
// Register each action tool as a static handler in the executor
for (const [toolId, tool] of this.toolMap) {
const handler: StaticToolHandler = {
execute: async (args: ToolInput, context: ToolProviderContext) =>
tool.execute(args, {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
}),
};
this.toolExecutorService.registerStaticHandler(toolId, handler);
}
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Action tools are always available (individual tool permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
const executionContext: ToolExecutionContext = {
workspaceId: context.workspaceId,
userId: context.userId,
userWorkspaceId: context.userWorkspaceId,
onCodeExecutionUpdate: context.onCodeExecutionUpdate,
};
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
const hasHttpPermission = await this.permissionsService.hasToolPermission(
context.rolePermissionConfig,
@@ -59,10 +75,7 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasHttpPermission) {
tools['http_request'] = this.createToolEntry(
this.httpTool,
executionContext,
);
descriptors.push(this.buildDescriptor('http_request', this.httpTool));
}
const hasEmailPermission = await this.permissionsService.hasToolPermission(
@@ -72,15 +85,11 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasEmailPermission) {
tools['send_email'] = this.createToolEntry(
this.sendEmailTool,
executionContext,
);
descriptors.push(this.buildDescriptor('send_email', this.sendEmailTool));
}
tools['search_help_center'] = this.createToolEntry(
this.searchHelpCenterTool,
executionContext,
descriptors.push(
this.buildDescriptor('search_help_center', this.searchHelpCenterTool),
);
const hasCodeInterpreterPermission =
@@ -91,23 +100,21 @@ export class ActionToolProvider implements ToolProvider {
);
if (hasCodeInterpreterPermission) {
tools['code_interpreter'] = this.createToolEntry(
this.codeInterpreterTool,
executionContext,
descriptors.push(
this.buildDescriptor('code_interpreter', this.codeInterpreterTool),
);
}
return tools;
return descriptors;
}
private createToolEntry(tool: Tool, context: ToolExecutionContext) {
private buildDescriptor(toolId: string, tool: Tool): ToolDescriptor {
return {
name: toolId,
description: tool.description,
inputSchema: wrapSchemaForExecution(
tool.inputSchema as ZodObject<ZodRawShape>,
),
execute: async (parameters: ToolInput) =>
tool.execute(stripLoadingMessage(parameters), context),
category: ToolCategory.ACTION,
inputSchema: z.toJSONSchema(tool.inputSchema as z.ZodType),
executionRef: { kind: 'static', toolId },
};
}
}
@@ -1,6 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -10,11 +9,14 @@ import {
import { DASHBOARD_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/dashboard-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { DashboardToolWorkspaceService } from 'src/modules/dashboard/tools/services/dashboard-tool.workspace-service';
@Injectable()
export class DashboardToolProvider implements ToolProvider {
export class DashboardToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.DASHBOARD;
constructor(
@@ -22,8 +24,24 @@ export class DashboardToolProvider implements ToolProvider {
@Inject(DASHBOARD_TOOL_SERVICE_TOKEN)
private readonly dashboardToolService: DashboardToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
if (this.dashboardToolService) {
const service = this.dashboardToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.DASHBOARD,
async (context) =>
service.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.dashboardToolService) {
return false;
@@ -36,14 +54,18 @@ export class DashboardToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
if (!this.dashboardToolService) {
return {};
return [];
}
return this.dashboardToolService.generateDashboardTools(
const toolSet = await this.dashboardToolService.generateDashboardTools(
context.workspaceId,
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.DASHBOARD);
}
}
@@ -1,13 +1,11 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { camelToSnakeCase, isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
import {
type ToolProvider,
@@ -15,20 +13,16 @@ import {
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { createDirectRecordToolsFactory } from 'src/engine/core-modules/record-crud/tool-factory/direct-record-tools.factory';
import { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
import { generateCreateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-record-input-schema.util';
import { generateUpdateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-many-record-input-schema.util';
import { generateUpdateRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-update-record-input-schema.util';
import { DeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/delete-tool.zod-schema';
import { FindOneToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-one-tool.zod-schema';
import { generateFindToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/find-tool.zod-schema';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { isFavoriteRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-favorite-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent/utils/is-workflow-related-object.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
@@ -41,66 +35,21 @@ export class DatabaseToolProvider implements ToolProvider {
constructor(
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly createRecordService: CreateRecordService,
private readonly updateRecordService: UpdateRecordService,
private readonly deleteRecordService: DeleteRecordService,
private readonly findRecordsService: FindRecordsService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Database tools are always available (per-object permissions checked in generateTools)
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
const tools: ToolSet = {};
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const descriptors: ToolDescriptor[] = [];
// Both userId and userWorkspaceId are required for user-based tool generation
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
return tools;
return descriptors;
}
const user = await this.userRepository.findOne({
where: {
id: context.userId,
},
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
const workspaceMember = isDefined(workspaceMemberId)
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
: undefined;
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
throw new AuthException(
'Workspace member not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const authContext: WorkspaceAuthContext = buildUserAuthContext({
workspace: { id: context.workspaceId } as WorkspaceEntity,
userWorkspaceId: context.userWorkspaceId,
user,
workspaceMemberId,
workspaceMember,
});
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'rolesPermissions',
@@ -112,7 +61,7 @@ export class DatabaseToolProvider implements ToolProvider {
);
if (!objectPermissions) {
return tools;
return descriptors;
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
@@ -127,17 +76,13 @@ export class DatabaseToolProvider implements ToolProvider {
flatObjectMetadataMaps.byUniversalIdentifier,
)
.filter(isDefined)
.filter((obj) => obj.isActive && !obj.isSystem);
const factory = createDirectRecordToolsFactory({
createRecordService: this.createRecordService,
updateRecordService: this.updateRecordService,
deleteRecordService: this.deleteRecordService,
findRecordsService: this.findRecordsService,
});
.filter((obj) => obj.isActive);
for (const flatObject of allFlatObjects) {
if (isWorkflowRelatedObject(flatObject)) {
if (
isWorkflowRelatedObject(flatObject) ||
isFavoriteRelatedObject(flatObject)
) {
continue;
}
@@ -155,27 +100,132 @@ export class DatabaseToolProvider implements ToolProvider {
),
};
const objectTools = factory(
{
objectMetadata,
restrictedFields: permission.restrictedFields,
canCreate: permission.canUpdateObjectRecords,
canRead: permission.canReadObjectRecords,
canUpdate: permission.canUpdateObjectRecords,
canDelete: permission.canSoftDeleteObjectRecords,
},
{
workspaceId: context.workspaceId,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
actorContext: context.actorContext,
},
);
const restrictedFields = permission.restrictedFields;
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
Object.assign(tools, objectTools);
if (permission.canReadObjectRecords) {
descriptors.push({
name: `find_${snakePlural}`,
description: `Search for ${objectMetadata.labelPlural} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. To find by ID, use filter: { id: { eq: "record-id" } }. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'find',
},
objectName: objectMetadata.nameSingular,
operation: 'find',
});
descriptors.push({
name: `find_one_${snakeSingular}`,
description: `Retrieve a single ${objectMetadata.labelSingular} record by its unique ID. Use this when you know the exact record ID and need the complete record data. Returns the full record or an error if not found.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'find_one',
},
objectName: objectMetadata.nameSingular,
operation: 'find_one',
});
}
if (permission.canUpdateObjectRecords) {
descriptors.push({
name: `create_${snakeSingular}`,
description: `Create a new ${objectMetadata.labelSingular} record. Provide all required fields and any optional fields you want to set. The system will automatically handle timestamps and IDs. Returns the created record with all its data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'create',
},
objectName: objectMetadata.nameSingular,
operation: 'create',
});
descriptors.push({
name: `create_many_${snakePlural}`,
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,
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'create_many',
},
objectName: objectMetadata.nameSingular,
operation: 'create_many',
});
descriptors.push({
name: `update_${snakeSingular}`,
description: `Update an existing ${objectMetadata.labelSingular} record. Provide the record ID and only the fields you want to change. Unspecified fields will remain unchanged. Returns the updated record with all current data.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'update',
},
objectName: objectMetadata.nameSingular,
operation: 'update',
});
descriptors.push({
name: `update_many_${snakePlural}`,
description: `Update multiple ${objectMetadata.labelPlural} records matching a filter in a single operation. All matching records will receive the same field values. WARNING: Use specific filters to avoid unintended mass updates. Always verify the filter scope with a find query first. Returns the updated records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
restrictedFields,
),
),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'update_many',
},
objectName: objectMetadata.nameSingular,
operation: 'update_many',
});
}
if (permission.canSoftDeleteObjectRecords) {
descriptors.push({
name: `delete_${snakeSingular}`,
description: `Delete a ${objectMetadata.labelSingular} record by marking it as deleted. The record is hidden from normal queries. This is reversible. Use this to remove records.`,
category: ToolCategory.DATABASE_CRUD,
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
executionRef: {
kind: 'database_crud',
objectNameSingular: objectMetadata.nameSingular,
operation: 'delete',
},
objectName: objectMetadata.nameSingular,
operation: 'delete',
});
}
}
return tools;
return descriptors;
}
private getObjectPermissions(
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { jsonSchema, type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -9,26 +8,25 @@ import {
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type FlatLogicFunction } from 'src/engine/metadata-modules/logic-function/types/flat-logic-function.type';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
@Injectable()
export class LogicFunctionToolProvider implements ToolProvider {
readonly category = ToolCategory.LOGIC_FUNCTION;
constructor(
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
// Logic function tools are available if there are any functions marked as tools
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const { flatLogicFunctionMaps } =
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
@@ -37,7 +35,6 @@ export class LogicFunctionToolProvider implements ToolProvider {
},
);
// Filter logic functions that are marked as tools
const logicFunctionsWithSchema = Object.values(
flatLogicFunctionMaps.byUniversalIdentifier,
).filter(
@@ -45,49 +42,35 @@ export class LogicFunctionToolProvider implements ToolProvider {
isDefined(fn) && fn.isTool === true && fn.deletedAt === null,
);
const tools: ToolSet = {};
const descriptors: ToolDescriptor[] = [];
for (const logicFunction of logicFunctionsWithSchema) {
const toolName = this.buildLogicFunctionToolName(logicFunction.name);
const wrappedSchema = wrapJsonSchemaForExecution(
logicFunction.toolInputSchema as Record<string, unknown>,
);
// Logic functions already store JSON Schema -- use it directly
const inputSchema = (logicFunction.toolInputSchema as object) ?? {
type: 'object',
properties: {},
};
tools[toolName] = {
descriptors.push({
name: toolName,
description:
logicFunction.description ||
`Execute the ${logicFunction.name} logic function`,
inputSchema: jsonSchema(wrappedSchema),
execute: async (parameters: Record<string, unknown>) => {
const { loadingMessage: _, ...actualParams } = parameters;
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: logicFunction.id,
workspaceId: context.workspaceId,
payload: actualParams,
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
category: ToolCategory.LOGIC_FUNCTION,
inputSchema,
executionRef: {
kind: 'logic_function',
logicFunctionId: logicFunction.id,
},
};
});
}
return tools;
return descriptors;
}
private buildLogicFunctionToolName(functionName: string): string {
// Convert function name to a valid tool name (lowercase, underscores)
return `logic_function_${functionName
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -9,20 +8,37 @@ import {
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { FieldMetadataToolsFactory } from 'src/engine/metadata-modules/field-metadata/tools/field-metadata-tools.factory';
import { ObjectMetadataToolsFactory } from 'src/engine/metadata-modules/object-metadata/tools/object-metadata-tools.factory';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class MetadataToolProvider implements ToolProvider {
export class MetadataToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.METADATA;
constructor(
private readonly objectMetadataToolsFactory: ObjectMetadataToolsFactory,
private readonly fieldMetadataToolsFactory: FieldMetadataToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const objectFactory = this.objectMetadataToolsFactory;
const fieldFactory = this.fieldMetadataToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.METADATA,
async (context) => ({
...objectFactory.generateTools(context.workspaceId),
...fieldFactory.generateTools(context.workspaceId),
}),
);
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
return this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
@@ -31,10 +47,14 @@ export class MetadataToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
return {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const toolSet = {
...this.objectMetadataToolsFactory.generateTools(context.workspaceId),
...this.fieldMetadataToolsFactory.generateTools(context.workspaceId),
};
return toolSetToDescriptors(toolSet, ToolCategory.METADATA);
}
}
@@ -4,7 +4,7 @@ import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import {
type ToolProvider,
type NativeToolProvider,
type ToolProviderContext,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
@@ -12,8 +12,10 @@ import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-c
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
// SDK-native tools (anthropic webSearch, etc.) are opaque and not serializable.
// This provider keeps generateTools() and is excluded from the descriptor system.
@Injectable()
export class NativeModelToolProvider implements ToolProvider {
export class NativeModelToolProvider implements NativeToolProvider {
readonly category = ToolCategory.NATIVE_MODEL;
constructor(
@@ -1,6 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Injectable, OnModuleInit } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -9,23 +8,64 @@ import {
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { ViewToolsFactory } from 'src/engine/metadata-modules/view/tools/view-tools.factory';
@Injectable()
export class ViewToolProvider implements ToolProvider {
export class ViewToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.VIEW;
constructor(
private readonly viewToolsFactory: ViewToolsFactory,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
const factory = this.viewToolsFactory;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.VIEW,
async (context) => {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = factory.generateReadTools(
context.workspaceId,
workspaceMemberId ?? undefined,
workspaceMemberId ?? undefined,
);
const hasViewPermission =
await this.permissionsService.checkRolesPermissions(
context.rolePermissionConfig,
context.workspaceId,
PermissionFlagType.VIEWS,
);
if (hasViewPermission) {
const writeTools = factory.generateWriteTools(
context.workspaceId,
workspaceMemberId ?? undefined,
);
return { ...readTools, ...writeTools };
}
return readTools;
},
);
}
async isAvailable(_context: ToolProviderContext): Promise<boolean> {
return true;
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
const workspaceMemberId = context.actorContext?.workspaceMemberId;
const readTools = this.viewToolsFactory.generateReadTools(
@@ -47,9 +87,12 @@ export class ViewToolProvider implements ToolProvider {
workspaceMemberId ?? undefined,
);
return { ...readTools, ...writeTools };
return toolSetToDescriptors(
{ ...readTools, ...writeTools },
ToolCategory.VIEW,
);
}
return readTools;
return toolSetToDescriptors(readTools, ToolCategory.VIEW);
}
}
@@ -1,6 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common';
import { Inject, Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { PermissionFlagType } from 'twenty-shared/constants';
import {
@@ -10,11 +9,14 @@ import {
import { WORKFLOW_TOOL_SERVICE_TOKEN } from 'src/engine/core-modules/tool-provider/constants/workflow-tool-service.token';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { toolSetToDescriptors } from 'src/engine/core-modules/tool-provider/utils/tool-set-to-descriptors.util';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import type { WorkflowToolWorkspaceService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
@Injectable()
export class WorkflowToolProvider implements ToolProvider {
export class WorkflowToolProvider implements ToolProvider, OnModuleInit {
readonly category = ToolCategory.WORKFLOW;
constructor(
@@ -22,8 +24,24 @@ export class WorkflowToolProvider implements ToolProvider {
@Inject(WORKFLOW_TOOL_SERVICE_TOKEN)
private readonly workflowToolService: WorkflowToolWorkspaceService | null,
private readonly permissionsService: PermissionsService,
private readonly toolExecutorService: ToolExecutorService,
) {}
onModuleInit(): void {
if (this.workflowToolService) {
const service = this.workflowToolService;
this.toolExecutorService.registerCategoryGenerator(
ToolCategory.WORKFLOW,
async (context) =>
service.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
),
);
}
}
async isAvailable(context: ToolProviderContext): Promise<boolean> {
if (!this.workflowToolService) {
return false;
@@ -36,14 +54,18 @@ export class WorkflowToolProvider implements ToolProvider {
);
}
async generateTools(context: ToolProviderContext): Promise<ToolSet> {
async generateDescriptors(
context: ToolProviderContext,
): Promise<ToolDescriptor[]> {
if (!this.workflowToolService) {
return {};
return [];
}
return this.workflowToolService.generateWorkflowTools(
const toolSet = await this.workflowToolService.generateWorkflowTools(
context.workspaceId,
context.rolePermissionConfig,
);
return toolSetToDescriptors(toolSet, ToolCategory.WORKFLOW);
}
}
@@ -0,0 +1,310 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { buildUserAuthContext } from 'src/engine/core-modules/auth/utils/build-user-auth-context.util';
import { LogicFunctionExecutorService } from 'src/engine/core-modules/logic-function/logic-function-executor/logic-function-executor.service';
import { CreateManyRecordsService } from 'src/engine/core-modules/record-crud/services/create-many-records.service';
import { CreateRecordService } from 'src/engine/core-modules/record-crud/services/create-record.service';
import { DeleteRecordService } from 'src/engine/core-modules/record-crud/services/delete-record.service';
import { FindRecordsService } from 'src/engine/core-modules/record-crud/services/find-records.service';
import { UpdateManyRecordsService } from 'src/engine/core-modules/record-crud/services/update-many-records.service';
import { UpdateRecordService } from 'src/engine/core-modules/record-crud/services/update-record.service';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolInput } from 'src/engine/core-modules/tool/types/tool-input.type';
import { stripLoadingMessage } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
// Handler for individually registered static tools (e.g., action tools)
export interface StaticToolHandler {
execute(args: ToolInput, context: ToolProviderContext): Promise<unknown>;
}
// Generator that produces a ToolSet on demand for a category (workflow, view, etc.)
// Used as a fallback when no per-tool handler is registered.
export type CategoryToolGenerator = (
context: ToolProviderContext,
) => Promise<ToolSet>;
@Injectable()
export class ToolExecutorService {
private readonly logger = new Logger(ToolExecutorService.name);
// Per-tool handlers (action tools, etc.)
private readonly staticToolHandlers = new Map<string, StaticToolHandler>();
// Category-level ToolSet generators (workflow, view, dashboard, metadata)
private readonly categoryGenerators = new Map<
ToolCategory,
CategoryToolGenerator
>();
constructor(
private readonly findRecordsService: FindRecordsService,
private readonly createRecordService: CreateRecordService,
private readonly createManyRecordsService: CreateManyRecordsService,
private readonly updateRecordService: UpdateRecordService,
private readonly updateManyRecordsService: UpdateManyRecordsService,
private readonly deleteRecordService: DeleteRecordService,
private readonly logicFunctionExecutorService: LogicFunctionExecutorService,
private readonly workspaceCacheService: WorkspaceCacheService,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
) {}
registerStaticHandler(toolId: string, handler: StaticToolHandler): void {
this.staticToolHandlers.set(toolId, handler);
}
registerCategoryGenerator(
category: ToolCategory,
generator: CategoryToolGenerator,
): void {
this.categoryGenerators.set(category, generator);
}
async dispatch(
descriptor: ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const cleanArgs = stripLoadingMessage(args);
switch (descriptor.executionRef.kind) {
case 'database_crud':
return this.dispatchDatabaseCrud(
descriptor.executionRef,
cleanArgs,
context,
);
case 'static':
return this.dispatchStaticTool(descriptor, cleanArgs, context);
case 'logic_function':
return this.dispatchLogicFunction(
descriptor.executionRef,
cleanArgs,
context,
);
}
}
private async dispatchDatabaseCrud(
ref: { objectNameSingular: string; operation: string },
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const authContext =
context.authContext ?? (await this.buildAuthContext(context));
switch (ref.operation) {
case 'find': {
const { limit, offset, orderBy, ...filter } = args;
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter,
orderBy: orderBy as never,
limit: limit as number | undefined,
offset: offset as number | undefined,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
}
case 'find_one':
return this.findRecordsService.execute({
objectName: ref.objectNameSingular,
filter: { id: { eq: args.id } },
limit: 1,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
});
case 'create':
return this.createRecordService.execute({
objectName: ref.objectNameSingular,
objectRecord: args,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
slimResponse: true,
});
case 'create_many':
return this.createManyRecordsService.execute({
objectName: ref.objectNameSingular,
objectRecords: args.records as Record<string, unknown>[],
authContext,
rolePermissionConfig: context.rolePermissionConfig,
createdBy: context.actorContext,
slimResponse: true,
});
case 'update': {
const { id, ...fields } = args;
const objectRecord = Object.fromEntries(
Object.entries(fields).filter(([, value]) => value !== undefined),
);
return this.updateRecordService.execute({
objectName: ref.objectNameSingular,
objectRecordId: id as string,
objectRecord,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
slimResponse: true,
});
}
case 'update_many':
return this.updateManyRecordsService.execute({
objectName: ref.objectNameSingular,
filter: args.filter as Record<string, unknown>,
data: args.data as Record<string, unknown>,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
slimResponse: true,
});
case 'delete':
return this.deleteRecordService.execute({
objectName: ref.objectNameSingular,
objectRecordId: args.id as string,
authContext,
rolePermissionConfig: context.rolePermissionConfig,
soft: true,
});
default:
throw new Error(`Unknown database_crud operation: ${ref.operation}`);
}
}
private async dispatchStaticTool(
descriptor: ToolDescriptor,
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
if (descriptor.executionRef.kind !== 'static') {
throw new Error('Expected static executionRef');
}
// Per-tool handler first (action tools)
const handler = this.staticToolHandlers.get(descriptor.executionRef.toolId);
if (handler) {
return handler.execute(args, context);
}
// Category-level generator fallback (workflow, view, dashboard, metadata)
const generator = this.categoryGenerators.get(descriptor.category);
if (!generator) {
throw new Error(
`No handler or generator for static tool: ${descriptor.executionRef.toolId}`,
);
}
const toolSet = await generator(context);
const tool = toolSet[descriptor.name];
if (!tool?.execute) {
throw new Error(
`Tool ${descriptor.name} not found in generated ToolSet for category ${descriptor.category}`,
);
}
// The tool's execute expects (args, ToolCallOptions). Pass args with
// a dummy loadingMessage since the tool's internal strip is harmless.
return tool.execute(
{ loadingMessage: '', ...args },
{ toolCallId: '', messages: [] },
);
}
private async dispatchLogicFunction(
ref: { logicFunctionId: string },
args: Record<string, unknown>,
context: ToolProviderContext,
): Promise<unknown> {
const result = await this.logicFunctionExecutorService.execute({
logicFunctionId: ref.logicFunctionId,
workspaceId: context.workspaceId,
payload: args,
});
if (result.error) {
return {
success: false,
error: result.error.errorMessage,
};
}
return {
success: true,
result: result.data,
};
}
// Build authContext on demand for database CRUD operations
private async buildAuthContext(
context: ToolProviderContext,
): Promise<WorkspaceAuthContext> {
if (!isDefined(context.userId) || !isDefined(context.userWorkspaceId)) {
throw new AuthException(
'userId and userWorkspaceId are required for database operations',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const user = await this.userRepository.findOne({
where: { id: context.userId },
});
if (!isDefined(user)) {
throw new AuthException(
'User not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
const { flatWorkspaceMemberMaps } =
await this.workspaceCacheService.getOrRecompute(context.workspaceId, [
'flatWorkspaceMemberMaps',
]);
const workspaceMemberId = flatWorkspaceMemberMaps.idByUserId[user.id];
const workspaceMember = isDefined(workspaceMemberId)
? flatWorkspaceMemberMaps.byId[workspaceMemberId]
: undefined;
if (!isDefined(workspaceMemberId) || !isDefined(workspaceMember)) {
throw new AuthException(
'Workspace member not found',
AuthExceptionCode.UNAUTHENTICATED,
);
}
return buildUserAuthContext({
workspace: { id: context.workspaceId } as WorkspaceEntity,
userWorkspaceId: context.userWorkspaceId,
user,
workspaceMemberId,
workspaceMember,
});
}
}
@@ -1,46 +1,34 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { type ToolSet, zodSchema } from 'ai';
import { type ToolCallOptions, type ToolSet, jsonSchema } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
import { type ZodType } from 'zod';
import {
type CodeExecutionStreamEmitter,
type NativeToolProvider,
type ToolProvider,
type ToolProviderContext,
type ToolRetrievalOptions,
} from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { TOOL_PROVIDERS } from 'src/engine/core-modules/tool-provider/constants/tool-providers.token';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { compactToolOutput } from 'src/engine/core-modules/tool-provider/output-serialization/compact-tool-output.util';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { type ExecuteToolResult } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
import { type LearnToolsAspect } from 'src/engine/core-modules/tool-provider/tools/learn-tools.tool';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { wrapJsonSchemaForExecution } from 'src/engine/core-modules/tool/utils/wrap-tool-for-execution.util';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/providers/native-model-tool.provider';
export type ToolIndexEntry = {
name: string;
description: string;
category:
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'LOGIC_FUNCTION';
objectName?: string;
operation?: string;
inputSchema?: object;
};
// Backward-compatible alias -- consumers can import this instead of ToolDescriptor
export type ToolIndexEntry = ToolDescriptor;
export type ToolSearchOptions = {
limit?: number;
category?:
| 'DATABASE'
| 'ACTION'
| 'WORKFLOW'
| 'METADATA'
| 'VIEW'
| 'DASHBOARD'
| 'LOGIC_FUNCTION';
category?: ToolCategory;
};
export type ToolContext = {
@@ -52,20 +40,119 @@ export type ToolContext = {
onCodeExecutionUpdate?: CodeExecutionStreamEmitter;
};
const RAM_TTL_MS = 5_000;
const REDIS_TTL_MS = 300_000;
@Injectable()
export class ToolRegistryService {
private readonly logger = new Logger(ToolRegistryService.name);
// Two-tier cache: RAM (5s) → Redis (5min) → generate from providers
private readonly ramCache = new Map<
string,
{ descriptors: ToolDescriptor[]; cachedAt: number }
>();
constructor(
@Inject(TOOL_PROVIDERS)
private readonly providers: ToolProvider[],
private readonly nativeModelToolProvider: NativeModelToolProvider,
private readonly toolExecutorService: ToolExecutorService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
// Core: returns cached ToolDescriptor[] for a workspace+role+user
async getCatalog(context: ToolProviderContext): Promise<ToolDescriptor[]> {
const cacheKey = await this.buildCacheKey(context);
// 1. RAM hit?
const ramEntry = this.ramCache.get(cacheKey);
if (ramEntry && Date.now() - ramEntry.cachedAt < RAM_TTL_MS) {
return ramEntry.descriptors;
}
// 2. Redis hit?
const redisData =
await this.workspaceCacheStorageService.getToolCatalog(cacheKey);
if (redisData) {
const descriptors = redisData as ToolDescriptor[];
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
return descriptors;
}
// 3. Generate from providers (cache miss)
const descriptors: ToolDescriptor[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const providerDescriptors = await provider.generateDescriptors(context);
descriptors.push(...providerDescriptors);
}
}
this.logger.log(
`Generated ${descriptors.length} tool descriptors for workspace ${context.workspaceId}`,
);
// Store in both caches
this.ramCache.set(cacheKey, {
descriptors,
cachedAt: Date.now(),
});
await this.workspaceCacheStorageService.setToolCatalog(
cacheKey,
descriptors,
REDIS_TTL_MS,
);
return descriptors;
}
// Hydrate ToolDescriptor[] into an AI SDK ToolSet with thin dispatch closures
hydrateToolSet(
descriptors: ToolDescriptor[],
context: ToolProviderContext,
options?: { wrapWithErrorContext?: boolean },
): ToolSet {
const toolSet: ToolSet = {};
for (const descriptor of descriptors) {
// Add loadingMessage to the clean stored schema
const schemaWithLoading = wrapJsonSchemaForExecution(
descriptor.inputSchema as Record<string, unknown>,
);
const executeFn = async (
args: Record<string, unknown>,
): Promise<unknown> =>
this.toolExecutorService.dispatch(descriptor, args, context);
toolSet[descriptor.name] = {
description: descriptor.description,
inputSchema: jsonSchema(schemaWithLoading),
execute: options?.wrapWithErrorContext
? this.wrapWithErrorHandler(descriptor.name, executeFn)
: executeFn,
};
}
return toolSet;
}
async buildToolIndex(
workspaceId: string,
roleId: string,
options?: { userId?: string; userWorkspaceId?: string },
): Promise<ToolIndexEntry[]> {
): Promise<ToolDescriptor[]> {
const context = this.buildContext(
workspaceId,
roleId,
@@ -73,21 +160,8 @@ export class ToolRegistryService {
options?.userId,
options?.userWorkspaceId,
);
const entries: ToolIndexEntry[] = [];
for (const provider of this.providers) {
if (await provider.isAvailable(context)) {
const tools = await provider.generateTools(context);
entries.push(...this.toolSetToIndex(tools, provider.category));
}
}
this.logger.log(
`Built tool index with ${entries.length} tools for workspace ${workspaceId}`,
);
return entries;
return this.getCatalog(context);
}
async searchTools(
@@ -98,19 +172,24 @@ export class ToolRegistryService {
userId?: string;
userWorkspaceId?: string;
} = {},
): Promise<ToolIndexEntry[]> {
): Promise<ToolDescriptor[]> {
const { limit = 5, category, userId, userWorkspaceId } = options;
const index = await this.buildToolIndex(workspaceId, roleId, {
const context = this.buildContext(
workspaceId,
roleId,
undefined,
userId,
userWorkspaceId,
});
);
const descriptors = await this.getCatalog(context);
const queryLower = query.toLowerCase();
const queryTerms = queryLower
.split(/\s+/)
.filter((term) => term.length > 2);
const scored = index
const scored = descriptors
.filter((tool) => !category || tool.category === category)
.map((tool) => {
let score = 0;
@@ -171,59 +250,168 @@ export class ToolRegistryService {
context.userId,
context.userWorkspaceId,
);
const allTools: ToolSet = {};
for (const provider of this.providers) {
if (await provider.isAvailable(fullContext)) {
const tools = await provider.generateTools(fullContext);
Object.assign(allTools, tools);
}
}
return Object.fromEntries(
names
.filter((name) => name in allTools)
.map((name) => [name, allTools[name]]),
const descriptors = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((descriptor) =>
nameSet.has(descriptor.name),
);
return this.hydrateToolSet(filtered, fullContext);
}
// Main method for eager loading tools by categories (replaces ToolProviderService.getTools)
async getToolInfo(
names: string[],
context: ToolContext,
aspects: LearnToolsAspect[] = ['description', 'schema'],
): Promise<
Array<{ name: string; description?: string; inputSchema?: object }>
> {
const fullContext = this.buildContext(
context.workspaceId,
context.roleId,
context.onCodeExecutionUpdate,
context.userId,
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const nameSet = new Set(names);
const filtered = descriptors.filter((entry) => nameSet.has(entry.name));
return filtered.map((entry) => {
const info: {
name: string;
description?: string;
inputSchema?: object;
} = { name: entry.name };
if (aspects.includes('description')) {
info.description = entry.description;
}
if (aspects.includes('schema')) {
info.inputSchema = entry.inputSchema;
}
return info;
});
}
async resolveAndExecute(
toolName: string,
args: Record<string, unknown>,
context: ToolContext,
_options: ToolCallOptions,
): Promise<ExecuteToolResult> {
try {
const fullContext = this.buildContext(
context.workspaceId,
context.roleId,
context.onCodeExecutionUpdate,
context.userId,
context.userWorkspaceId,
);
const descriptors = await this.getCatalog(fullContext);
const descriptor = descriptors.find((desc) => desc.name === toolName);
if (!descriptor) {
return {
toolName,
error: {
message: `Tool "${toolName}" not found. Check the tool catalog for correct names.`,
suggestion:
'Use learn_tools to discover available tools and their correct names.',
},
};
}
const result = await this.toolExecutorService.dispatch(
descriptor,
args,
fullContext,
);
return {
toolName,
result: compactToolOutput(result),
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
this.logger.error(`Error executing tool "${toolName}": ${errorMessage}`);
return {
toolName,
error: {
message: errorMessage,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
}
// Main method for eager loading tools by categories
async getToolsByCategories(
context: ToolProviderContext,
options: ToolRetrievalOptions = {},
): Promise<ToolSet> {
const { categories, excludeTools, wrapWithErrorContext } = options;
const tools: ToolSet = {};
const descriptors = await this.getCatalog(context);
for (const provider of this.providers) {
if (categories && !categories.includes(provider.category)) {
continue;
}
if (await provider.isAvailable(context)) {
const providerTools = await provider.generateTools(context);
let filteredDescriptors: ToolDescriptor[];
Object.assign(tools, providerTools);
}
if (categories) {
const categorySet = new Set(categories);
filteredDescriptors = descriptors.filter((descriptor) =>
categorySet.has(descriptor.category),
);
} else {
filteredDescriptors = [...descriptors];
}
// Apply excludeTools filter
if (excludeTools?.length) {
for (const toolType of excludeTools) {
delete tools[toolType.toLowerCase()];
const excludeSet = new Set(excludeTools);
filteredDescriptors = filteredDescriptors.filter(
(descriptor) => !excludeSet.has(descriptor.name),
);
}
const toolSet = this.hydrateToolSet(filteredDescriptors, context, {
wrapWithErrorContext,
});
// Handle NativeModelToolProvider separately (SDK-opaque tools)
if (categories?.includes(ToolCategory.NATIVE_MODEL)) {
if (await this.nativeModelToolProvider.isAvailable(context)) {
const nativeTools = await (
this.nativeModelToolProvider as NativeToolProvider
).generateTools(context);
Object.assign(toolSet, nativeTools);
}
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
`Generated ${Object.keys(toolSet).length} tools for categories: [${categories?.join(', ') ?? 'all'}]`,
);
// Apply error wrapping if requested
if (wrapWithErrorContext) {
return this.wrapToolsWithErrorContext(tools);
}
return toolSet;
}
return tools;
private async buildCacheKey(context: ToolProviderContext): Promise<string> {
const metadataVersion =
(await this.workspaceCacheStorageService.getMetadataVersion(
context.workspaceId,
)) ?? 0;
return `${context.workspaceId}:v${metadataVersion}:${context.roleId}:${context.userId ?? 'system'}`;
}
private buildContext(
@@ -247,143 +435,27 @@ export class ToolRegistryService {
};
}
private toolSetToIndex(
tools: ToolSet,
category: ToolCategory,
): ToolIndexEntry[] {
const categoryMap: Record<ToolCategory, ToolIndexEntry['category']> = {
DATABASE_CRUD: 'DATABASE',
ACTION: 'ACTION',
WORKFLOW: 'WORKFLOW',
METADATA: 'METADATA',
NATIVE_MODEL: 'ACTION',
VIEW: 'VIEW',
DASHBOARD: 'DASHBOARD',
LOGIC_FUNCTION: 'LOGIC_FUNCTION',
};
return Object.entries(tools).map(([name, tool]) => {
const inputSchema = this.extractJsonSchema(tool.inputSchema);
return {
name,
description: tool.description ?? '',
category: categoryMap[category],
inputSchema,
};
});
}
private extractJsonSchema(inputSchema: unknown): object | undefined {
if (!inputSchema) {
return undefined;
}
let schema: object | undefined;
// Check if it's a Zod schema (has _def property)
if (
typeof inputSchema === 'object' &&
inputSchema !== null &&
'_def' in inputSchema
) {
private wrapWithErrorHandler(
toolName: string,
executeFn: (args: Record<string, unknown>) => Promise<unknown>,
): (args: Record<string, unknown>) => Promise<unknown> {
return async (args: Record<string, unknown>) => {
try {
// Use AI SDK's zodSchema() to convert Zod to JSON Schema
const converted = zodSchema(inputSchema as ZodType);
return await executeFn(args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
schema = converted.jsonSchema as object;
} catch {
// If conversion fails, return undefined
return undefined;
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(toolName, errorMessage),
},
};
}
} else if (
// Check if AI SDK wrapped it with jsonSchema property
typeof inputSchema === 'object' &&
inputSchema !== null &&
'jsonSchema' in inputSchema
) {
schema = (inputSchema as { jsonSchema: object }).jsonSchema;
} else if (typeof inputSchema === 'object') {
// Return as-is if it's already an object (plain JSON schema)
schema = inputSchema as object;
}
if (!schema) {
return undefined;
}
return this.stripInternalFieldsFromSchema(schema);
}
// Remove internal fields (loadingMessage) from schema for display
private stripInternalFieldsFromSchema(schema: object): object {
const schemaObj = schema as Record<string, unknown>;
// Remove $schema property
const { $schema: _, ...rest } = schemaObj;
// Remove loadingMessage from properties if present
// loadingMessage is an internal field auto-injected for AI status updates
if (
rest.type === 'object' &&
rest.properties &&
typeof rest.properties === 'object'
) {
const properties = rest.properties as Record<string, unknown>;
const { loadingMessage: __, ...cleanProperties } = properties;
// Filter required array to remove loadingMessage if present
const required = Array.isArray(rest.required)
? rest.required.filter((field) => field !== 'loadingMessage')
: undefined;
return {
...rest,
properties: cleanProperties,
...(required && required.length > 0 ? { required } : {}),
};
}
return rest;
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
};
}
private generateErrorSuggestion(
@@ -12,6 +12,7 @@ import { NativeModelToolProvider } from 'src/engine/core-modules/tool-provider/p
import { LogicFunctionToolProvider } from 'src/engine/core-modules/tool-provider/providers/logic-function-tool.provider';
import { ViewToolProvider } from 'src/engine/core-modules/tool-provider/providers/view-tool.provider';
import { WorkflowToolProvider } from 'src/engine/core-modules/tool-provider/providers/workflow-tool.provider';
import { ToolExecutorService } from 'src/engine/core-modules/tool-provider/services/tool-executor.service';
import { ToolModule } from 'src/engine/core-modules/tool/tool.module';
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { AiAgentExecutionModule } from 'src/engine/metadata-modules/ai/ai-agent-execution/ai-agent-execution.module';
@@ -24,6 +25,7 @@ import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ViewModule } from 'src/engine/metadata-modules/view/view.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { ToolIndexResolver } from './resolvers/tool-index.resolver';
import { ToolRegistryService } from './services/tool-registry.service';
@@ -45,6 +47,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
PermissionsModule,
ViewModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
LogicFunctionModule,
UserRoleModule,
@@ -52,6 +55,7 @@ import { ToolRegistryService } from './services/tool-registry.service';
],
providers: [
ToolIndexResolver,
ToolExecutorService,
ActionToolProvider,
DashboardToolProvider,
DatabaseToolProvider,
@@ -61,13 +65,14 @@ import { ToolRegistryService } from './services/tool-registry.service';
ViewToolProvider,
WorkflowToolProvider,
{
// TOOL_PROVIDERS contains only providers implementing ToolProvider (generateDescriptors).
// NativeModelToolProvider is excluded -- it's injected separately in the registry.
provide: TOOL_PROVIDERS,
useFactory: (
actionProvider: ActionToolProvider,
dashboardProvider: DashboardToolProvider,
databaseProvider: DatabaseToolProvider,
metadataProvider: MetadataToolProvider,
nativeModelProvider: NativeModelToolProvider,
logicFunctionProvider: LogicFunctionToolProvider,
viewProvider: ViewToolProvider,
workflowProvider: WorkflowToolProvider,
@@ -76,7 +81,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
dashboardProvider,
databaseProvider,
metadataProvider,
nativeModelProvider,
logicFunctionProvider,
viewProvider,
workflowProvider,
@@ -86,7 +90,6 @@ import { ToolRegistryService } from './services/tool-registry.service';
DashboardToolProvider,
DatabaseToolProvider,
MetadataToolProvider,
NativeModelToolProvider,
LogicFunctionToolProvider,
ViewToolProvider,
WorkflowToolProvider,
@@ -0,0 +1,57 @@
import { type ToolCallOptions, type ToolSet } from 'ai';
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const EXECUTE_TOOL_TOOL_NAME = 'execute_tool';
export const executeToolInputSchema = z.object({
toolName: z.string().describe('Exact name of the tool to execute.'),
arguments: z
.record(z.string(), z.unknown())
.describe(
'Arguments to pass to the tool. Must match the schema from learn_tools.',
),
});
export type ExecuteToolInput = z.infer<typeof executeToolInputSchema>;
export type ExecuteToolResult = {
toolName: string;
result?: unknown;
error?: {
message: string;
suggestion: string;
};
};
export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
directTools?: ToolSet,
) => ({
description:
'Execute a tool by name. Use learn_tools first to discover the correct schema, then call this with the tool name and arguments.',
inputSchema: executeToolInputSchema,
execute: async (
parameters: ExecuteToolInput,
options: ToolCallOptions,
): Promise<ExecuteToolResult> => {
const { toolName, arguments: args } = parameters;
// Native provider tools and preloaded tools are already in the ToolSet;
// dispatch directly if the LLM routes them through execute_tool.
const directTool = directTools?.[toolName];
if (directTool?.execute) {
const result = await directTool.execute(args, options);
return { toolName, result };
}
return toolRegistry.resolveAndExecute(toolName, args, context, options);
},
});
@@ -1,11 +1,19 @@
export {
LOAD_TOOLS_TOOL_NAME,
createLoadToolsTool,
loadToolsInputSchema,
type DynamicToolStore,
type LoadToolsInput,
type LoadToolsResult,
} from './load-tools.tool';
LEARN_TOOLS_TOOL_NAME,
createLearnToolsTool,
learnToolsInputSchema,
type LearnToolsAspect,
type LearnToolsInput,
type LearnToolsResult,
} from './learn-tools.tool';
export {
EXECUTE_TOOL_TOOL_NAME,
createExecuteToolTool,
executeToolInputSchema,
type ExecuteToolInput,
type ExecuteToolResult,
} from './execute-tool.tool';
export {
LOAD_SKILL_TOOL_NAME,
@@ -0,0 +1,74 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const LEARN_TOOLS_TOOL_NAME = 'learn_tools';
const learnToolsAspectSchema = z.enum(['description', 'schema']);
export type LearnToolsAspect = z.infer<typeof learnToolsAspectSchema>;
export const learnToolsInputSchema = z.object({
toolNames: z
.array(z.string())
.describe(
'Tool names to learn about. Use exact names from the tool catalog.',
),
aspects: z
.array(learnToolsAspectSchema)
.optional()
.default(['description', 'schema'])
.describe('What to learn: description, schema, or both.'),
});
export type LearnToolsInput = z.infer<typeof learnToolsInputSchema>;
export type LearnToolsResultEntry = {
name: string;
description?: string;
inputSchema?: object;
};
export type LearnToolsResult = {
tools: LearnToolsResultEntry[];
notFound: string[];
message: string;
};
export const createLearnToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
) => ({
description:
'Learn about tools before using them. Returns tool descriptions and/or input schemas so you know how to call them via execute_tool.',
inputSchema: learnToolsInputSchema,
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
const { toolNames, aspects } = parameters;
const toolInfos = await toolRegistry.getToolInfo(
toolNames,
context,
aspects,
);
const foundNames = new Set(toolInfos.map((t) => t.name));
const notFound = toolNames.filter((name) => !foundNames.has(name));
if (notFound.length > 0) {
return {
tools: toolInfos,
notFound,
message: `Learned ${toolInfos.length} tool(s). Could not find: ${notFound.join(', ')}.`,
};
}
return {
tools: toolInfos,
notFound: [],
message: `Learned ${toolInfos.length} tool(s): ${toolInfos.map((t) => t.name).join(', ')}.`,
};
},
});
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { type FlatSkill } from 'src/engine/metadata-modules/flat-skill/types/flat-skill.type';
export const LOAD_SKILL_TOOL_NAME = 'load_skill';
export const LOAD_SKILL_TOOL_NAME = 'load_skills';
export const loadSkillInputSchema = z.object({
skillNames: z
@@ -1,73 +0,0 @@
import { z } from 'zod';
import {
type ToolContext,
type ToolRegistryService,
} from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
export const LOAD_TOOLS_TOOL_NAME = 'load_tools' as const;
export const loadToolsInputSchema = z.object({
toolNames: z
.array(z.string())
.describe(
'Array of tool names to load. Use the exact names from the tool catalog.',
),
});
export type LoadToolsInput = z.infer<typeof loadToolsInputSchema>;
export type LoadToolsResult = {
loaded: string[];
notFound: string[];
message: string;
};
export type DynamicToolStore = {
loadedTools: Set<string>;
};
export const createLoadToolsTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
dynamicToolStore: DynamicToolStore,
onToolsLoaded: (toolNames: string[]) => Promise<void>,
) => ({
description: `Load tools by name to make them available for use. Call this when you need to use a tool from the catalog that isn't already loaded. You can load multiple tools at once.`,
inputSchema: loadToolsInputSchema,
execute: async (parameters: LoadToolsInput): Promise<LoadToolsResult> => {
const { toolNames } = parameters;
const loaded: string[] = [];
const notFound: string[] = [];
const tools = await toolRegistry.getToolsByName(toolNames, context);
for (const name of toolNames) {
if (tools[name]) {
loaded.push(name);
dynamicToolStore.loadedTools.add(name);
} else {
notFound.push(name);
}
}
if (loaded.length > 0) {
await onToolsLoaded(loaded);
}
if (notFound.length > 0) {
return {
loaded,
notFound,
message: `Loaded ${loaded.length} tool(s). Could not find: ${notFound.join(', ')}. Check the tool catalog for correct names.`,
};
}
return {
loaded,
notFound: [],
message: `Successfully loaded ${loaded.length} tool(s): ${loaded.join(', ')}. These tools are now available for use.`,
};
},
});
@@ -0,0 +1,30 @@
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
export type DatabaseCrudOperation =
| 'find'
| 'find_one'
| 'create'
| 'create_many'
| 'update'
| 'update_many'
| 'delete';
export type ToolExecutionRef =
| {
kind: 'database_crud';
objectNameSingular: string;
operation: DatabaseCrudOperation;
}
| { kind: 'static'; toolId: string }
| { kind: 'logic_function'; logicFunctionId: string };
// Fully JSON-serializable tool definition, stored in Redis
export type ToolDescriptor = {
name: string;
description: string;
category: ToolCategory;
inputSchema: object;
executionRef: ToolExecutionRef;
objectName?: string;
operation?: string;
};
@@ -0,0 +1,32 @@
import { type ToolSet } from 'ai';
import { z } from 'zod';
import { type ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
// Converts a ToolSet (with Zod schemas and closures) into an array of
// serializable ToolDescriptor objects. Used by providers that delegate to
// existing factory services (workflow, view, dashboard, metadata).
export const toolSetToDescriptors = (
toolSet: ToolSet,
category: ToolCategory,
): ToolDescriptor[] => {
return Object.entries(toolSet).map(([name, tool]) => {
let inputSchema: object;
try {
inputSchema = z.toJSONSchema(tool.inputSchema as z.ZodType);
} catch {
// Fallback: schema is already JSON Schema or another format
inputSchema = (tool.inputSchema ?? {}) as object;
}
return {
name,
description: tool.description ?? '',
category,
inputSchema,
executionRef: { kind: 'static' as const, toolId: name },
};
});
};
@@ -1262,6 +1262,15 @@ export class ConfigVariables {
@IsOptional()
XAI_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Groq integration',
type: ConfigVariableType.STRING,
})
@IsOptional()
GROQ_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.SERVER_CONFIG,
description: 'Enable or disable multi-workspace support',
@@ -117,6 +117,11 @@ export class UpdateWorkspaceInput {
@IsOptional()
smartModel?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
aiAdditionalInstructions?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@IsString({ each: true })
@@ -84,6 +84,7 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
defaultRoleId: PermissionFlagType.ROLES,
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
aiAdditionalInstructions: PermissionFlagType.WORKSPACE,
};
constructor(
@@ -296,6 +296,10 @@ export class WorkspaceEntity {
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
smartModel: ModelId;
@Field(() => String, { nullable: true })
@Column({ type: 'text', nullable: true })
aiAdditionalInstructions: string | null;
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;