perf(ai) - per-tool schema resolution in resolveSchemas (#21188)

learn_tools(["find_many_companies"]) goes from ~170ms → ~2ms (85x
faster, measured with 40 objects / empty fields — real workspaces would
see even bigger savings). Previously it generated schemas for all ~250
tools and discarded 249;

now it generates exactly the requested one(s).
This commit is contained in:
Etienne
2026-06-03 18:29:26 +02:00
committed by GitHub
parent c7365b09e2
commit 7fcbc45017
3 changed files with 52 additions and 19 deletions
@@ -1,3 +1,4 @@
export type GenerateDescriptorOptions = {
includeSchemas?: boolean;
toolNames?: Set<string>;
};
@@ -9,8 +9,8 @@ import { canObjectBeManagedByAutomation } from 'twenty-shared/workflow';
import { z } from 'zod';
import { type GenerateDescriptorOptions } from 'src/engine/core-modules/tool-provider/interfaces/generate-descriptor-options.type';
import { type ToolProvider } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider.interface';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { type ToolProvider } 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 { generateCreateManyRecordInputSchema } from 'src/engine/core-modules/record-crud/utils/generate-create-many-record-input-schema.util';
@@ -24,7 +24,6 @@ import {
generateGroupByToolInputSchema,
hasGroupByToolInputSchema,
} from 'src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema';
import { ToolCategory } from 'twenty-shared/ai';
import { type ToolDescriptor } from 'src/engine/core-modules/tool-provider/types/tool-descriptor.type';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
import { type ToolOutput } from 'src/engine/core-modules/tool/types/tool-output.type';
@@ -32,6 +31,7 @@ import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai/ai-agent
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';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { ToolCategory } from 'twenty-shared/ai';
@Injectable()
export class DatabaseToolProvider implements ToolProvider {
@@ -65,6 +65,7 @@ export class DatabaseToolProvider implements ToolProvider {
options?: GenerateDescriptorOptions,
): Promise<(ToolIndexEntry | ToolDescriptor)[]> {
const includeSchemas = options?.includeSchemas ?? true;
const toolNames = options?.toolNames;
const descriptors: (ToolIndexEntry | ToolDescriptor)[] = [];
const { rolesPermissions } =
@@ -106,6 +107,16 @@ export class DatabaseToolProvider implements ToolProvider {
continue;
}
const snakePlural = camelToSnakeCase(flatObject.namePlural);
const snakeSingular = camelToSnakeCase(flatObject.nameSingular);
if (
isDefined(toolNames) &&
!this.hasMatchingTool(toolNames, snakeSingular, snakePlural)
) {
continue;
}
const objectMetadata = {
...flatObject,
fields: getFlatFieldsFromFlatObjectMetadata(
@@ -115,18 +126,19 @@ export class DatabaseToolProvider implements ToolProvider {
};
const restrictedFields = permission.restrictedFields;
const snakePlural = camelToSnakeCase(objectMetadata.namePlural);
const snakeSingular = camelToSnakeCase(objectMetadata.nameSingular);
const canBeManagedByAutomation = canObjectBeManagedByAutomation({
nameSingular: objectMetadata.nameSingular,
});
const shouldIncludeSchema = (name: string) =>
includeSchemas && (!toolNames || toolNames.has(name));
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. Filter fields are top-level arguments — pass each field as its own key (e.g. { id: { eq: "record-id" } }, or { name: { firstName: { ilike: "%ada%" } } }); do NOT wrap them in a "filter" object and do NOT place a bare operator like "ilike"/"eq" at the top level. Combine conditions with and/or/not. Returns an array of matching records with their full data.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas && {
...(shouldIncludeSchema(`find_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
generateFindToolInputSchema(objectMetadata, restrictedFields),
),
@@ -145,7 +157,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`find_one_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(FindOneToolInputSchema),
}),
executionRef: {
@@ -158,7 +170,9 @@ export class DatabaseToolProvider implements ToolProvider {
operation: 'find_one',
});
const groupBySchema = includeSchemas
const groupByName = `group_by_${snakePlural}`;
const shouldGenerateGroupBy = shouldIncludeSchema(groupByName);
const groupBySchema = shouldGenerateGroupBy
? generateGroupByToolInputSchema(objectMetadata, restrictedFields)
: null;
const hasGroupBySchema =
@@ -167,10 +181,10 @@ export class DatabaseToolProvider implements ToolProvider {
if (hasGroupBySchema) {
descriptors.push({
name: `group_by_${snakePlural}`,
name: groupByName,
description: `Group ${objectMetadata.labelPlural} records by one or two fields and compute an aggregate (COUNT, SUM, AVG, MIN, MAX, etc.). Use for questions like "how many deals per stage?" or "total revenue by company". Returns groups with dimension values and aggregate results, ordered by the aggregate value.`,
category: ToolCategory.DATABASE_CRUD,
...(includeSchemas &&
...(shouldGenerateGroupBy &&
groupBySchema && {
inputSchema: z.toJSONSchema(groupBySchema),
}),
@@ -191,7 +205,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`create_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
generateCreateRecordInputSchema(objectMetadata, restrictedFields),
),
@@ -210,7 +224,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`create_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
generateCreateManyRecordInputSchema(
objectMetadata,
@@ -232,7 +246,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`update_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(
generateUpdateRecordInputSchema(objectMetadata, restrictedFields),
),
@@ -251,7 +265,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`update_many_${snakePlural}`) && {
inputSchema: z.toJSONSchema(
generateUpdateManyRecordInputSchema(
objectMetadata,
@@ -275,7 +289,7 @@ export class DatabaseToolProvider implements ToolProvider {
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,
...(includeSchemas && {
...(shouldIncludeSchema(`delete_${snakeSingular}`) && {
inputSchema: z.toJSONSchema(DeleteToolInputSchema),
}),
executionRef: {
@@ -293,6 +307,23 @@ export class DatabaseToolProvider implements ToolProvider {
return descriptors;
}
private hasMatchingTool(
toolNames: Set<string>,
snakeSingular: string,
snakePlural: string,
): boolean {
return (
toolNames.has(`find_${snakePlural}`) ||
toolNames.has(`find_one_${snakeSingular}`) ||
toolNames.has(`group_by_${snakePlural}`) ||
toolNames.has(`create_${snakeSingular}`) ||
toolNames.has(`create_many_${snakePlural}`) ||
toolNames.has(`update_${snakeSingular}`) ||
toolNames.has(`update_many_${snakePlural}`) ||
toolNames.has(`delete_${snakeSingular}`)
);
}
private getObjectPermissions(
rolesPermissions: ObjectsPermissionsByRoleId,
rolePermissionConfig: ToolProviderContext['rolePermissionConfig'],
@@ -80,13 +80,14 @@ export class ToolRegistryService {
continue;
}
const fullDescriptors = await provider.generateDescriptors(context, {
includeSchemas: true,
});
const entryNameSet = new Set(entries.map((entry) => entry.name));
for (const descriptor of fullDescriptors) {
const descriptors = await provider.generateDescriptors(context, {
includeSchemas: true,
toolNames: entryNameSet,
});
for (const descriptor of descriptors) {
if (
entryNameSet.has(descriptor.name) &&
'inputSchema' in descriptor &&