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:
co-authored by
claude[bot] <41898282+claude[bot]@users.noreply.github.com>
parent
6c7c389785
commit
3216b634a3
@@ -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)
|
||||
|
||||
+6
-6
@@ -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;
|
||||
}
|
||||
|
||||
+9
@@ -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,
|
||||
|
||||
+109
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+97
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-156
@@ -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;
|
||||
};
|
||||
};
|
||||
+14
@@ -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;
|
||||
};
|
||||
+1
@@ -4,4 +4,5 @@ import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-perm
|
||||
export type RecordCrudExecutionContext = {
|
||||
authContext: WorkspaceAuthContext;
|
||||
rolePermissionConfig?: RolePermissionConfig;
|
||||
slimResponse?: boolean;
|
||||
};
|
||||
|
||||
+14
@@ -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;
|
||||
};
|
||||
+26
@@ -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.',
|
||||
),
|
||||
});
|
||||
};
|
||||
+31
@@ -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.',
|
||||
),
|
||||
});
|
||||
};
|
||||
+7
@@ -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>;
|
||||
+10
-55
@@ -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()
|
||||
|
||||
+67
@@ -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 };
|
||||
};
|
||||
+7
-1
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
-10
@@ -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>;
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const COMMON_PRELOAD_TOOLS: string[] = ['search_help_center'];
|
||||
+12
-2
@@ -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>;
|
||||
}
|
||||
|
||||
+153
@@ -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();
|
||||
});
|
||||
});
|
||||
+13
@@ -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);
|
||||
};
|
||||
+32
@@ -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;
|
||||
};
|
||||
+34
@@ -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;
|
||||
};
|
||||
+49
-42
@@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+28
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+145
-95
@@ -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(
|
||||
|
||||
+19
-36
@@ -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, '_')
|
||||
|
||||
+25
-5
@@ -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
-2
@@ -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(
|
||||
|
||||
+49
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+28
-6
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+310
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
+282
-210
@@ -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(
|
||||
|
||||
+6
-3
@@ -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,
|
||||
|
||||
+57
@@ -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,
|
||||
|
||||
+74
@@ -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(', ')}.`,
|
||||
};
|
||||
},
|
||||
});
|
||||
+1
-1
@@ -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.`,
|
||||
};
|
||||
},
|
||||
});
|
||||
+30
@@ -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;
|
||||
};
|
||||
+32
@@ -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',
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user