Introduce agent hints to reduce context bloat (#15763)

Introducing a new pattern that should reduce token consumption by 90%
for the most common use-cases
This commit is contained in:
Félix Malfait
2025-11-13 16:15:01 +01:00
committed by GitHub
parent 261cba40ef
commit cd5d0f9eac
10 changed files with 286 additions and 155 deletions
@@ -138,7 +138,6 @@ describe('ToolService', () => {
expect(tools['create_testObject']).toBeDefined();
expect(tools['update_testObject']).toBeDefined();
expect(tools['find_testObject']).toBeDefined();
expect(tools['find_one_testObject']).toBeDefined();
expect(tools['soft_delete_testObject']).toBeDefined();
expect(tools['soft_delete_many_testObject']).toBeDefined();
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { type ActorMetadata } from 'twenty-shared/types';
@@ -10,10 +10,13 @@ import { UpdateRecordService } from 'src/engine/core-modules/record-crud/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 { BulkDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/bulk-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 { SoftDeleteToolInputSchema } from 'src/engine/core-modules/record-crud/zod-schemas/soft-delete-tool.zod-schema';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import {
type ToolHints,
type ToolOperation,
} from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -22,6 +25,8 @@ import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compu
@Injectable()
export class ToolService {
private readonly logger = new Logger(ToolService.name);
constructor(
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly objectMetadataService: ObjectMetadataService,
@@ -32,11 +37,15 @@ export class ToolService {
private readonly findRecordsService: FindRecordsService,
) {}
// Generates AI tools for database operations based on workspace objects and permissions
// Supports filtering by object names and operation types via toolHints
// Returns a map of tool names to tool definitions
async listTools(
rolePermissionConfig: RolePermissionConfig,
workspaceId: string,
actorContext?: ActorMetadata,
userWorkspaceId?: string,
toolHints?: ToolHints,
): Promise<ToolSet> {
const tools: ToolSet = {};
@@ -78,10 +87,42 @@ export class ToolService {
relations: ['fields'],
});
const filteredObjectMetadata = allObjectMetadata.filter(
let filteredObjectMetadata = allObjectMetadata.filter(
(objectMetadata) => !isWorkflowRelatedObject(objectMetadata),
);
if (toolHints?.relevantObjects && toolHints.relevantObjects.length > 0) {
const relevantSet = new Set(toolHints.relevantObjects);
const originalCount = filteredObjectMetadata.length;
filteredObjectMetadata = filteredObjectMetadata.filter(
(obj) =>
relevantSet.has(obj.nameSingular) || relevantSet.has(obj.namePlural),
);
this.logger.log(
`Tool filtering: reduced from ${originalCount} to ${filteredObjectMetadata.length} objects based on hints: ${toolHints.relevantObjects.join(', ')}`,
);
if (filteredObjectMetadata.length === 0) {
this.logger.warn(
`Tool filtering resulted in 0 objects. Hints may be incorrect: ${toolHints.relevantObjects.join(', ')}`,
);
}
}
const operationsSet = toolHints?.operations
? new Set(toolHints.operations)
: null;
const shouldIncludeOperation = (operation: ToolOperation) =>
!operationsSet || operationsSet.has(operation);
const shouldIncludeFind = shouldIncludeOperation('find');
const shouldIncludeCreate = shouldIncludeOperation('create');
const shouldIncludeUpdate = shouldIncludeOperation('update');
const shouldIncludeDelete = shouldIncludeOperation('delete');
filteredObjectMetadata.forEach((objectMetadata) => {
const objectPermission = objectPermissions[objectMetadata.id];
@@ -91,55 +132,9 @@ export class ToolService {
const restrictedFields = objectPermission.restrictedFields;
if (objectPermission.canUpdateObjectRecords) {
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) => {
return this.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
workspaceId,
rolePermissionConfig,
createdBy: actorContext,
userWorkspaceId,
});
},
};
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 { id, ...allFields } = parameters.input;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return this.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
workspaceId,
rolePermissionConfig,
userWorkspaceId,
});
},
};
}
if (objectPermission.canReadObjectRecords) {
if (shouldIncludeFind && objectPermission.canReadObjectRecords) {
tools[`find_${objectMetadata.nameSingular}`] = {
description: `Search for ${objectMetadata.labelSingular} records using flexible filtering criteria. Supports exact matches, pattern matching, ranges, and null checks. Use limit/offset for pagination and orderBy for sorting. Returns an array of matching records with their full data.`,
description: `Search for ${objectMetadata.labelSingular} 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,
@@ -159,24 +154,59 @@ export class ToolService {
});
},
};
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 this.findRecordsService.execute({
objectName: objectMetadata.nameSingular,
filter: { id: { eq: parameters.input.id } },
limit: 1,
workspaceId,
rolePermissionConfig,
userWorkspaceId,
});
},
};
}
if (objectPermission.canSoftDeleteObjectRecords) {
if (objectPermission.canUpdateObjectRecords) {
if (shouldIncludeCreate) {
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) => {
return this.createRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecord: parameters.input,
workspaceId,
rolePermissionConfig,
createdBy: actorContext,
userWorkspaceId,
});
},
};
}
if (shouldIncludeUpdate) {
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 { id, ...allFields } = parameters.input;
const objectRecord = Object.fromEntries(
Object.entries(allFields).filter(
([, value]) => value !== undefined,
),
);
return this.updateRecordService.execute({
objectName: objectMetadata.nameSingular,
objectRecordId: id,
objectRecord,
workspaceId,
rolePermissionConfig,
userWorkspaceId,
});
},
};
}
}
if (shouldIncludeDelete && objectPermission.canSoftDeleteObjectRecords) {
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,
@@ -207,6 +237,12 @@ export class ToolService {
}
});
if (operationsSet) {
this.logger.log(
`Tool filtering: included operations [${Array.from(operationsSet).join(', ')}]`,
);
}
return tools;
}
@@ -1,12 +1,8 @@
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
/**
* Converts cost in cents to cost in credits
* Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
* Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 ($0.00001 = 1 credit)
* Simplified: cents * 10000
* @param cents - Cost in cents (real cost)
* @returns Cost in credits (end-user cost)
*/
// Converts cost in cents to cost in credits
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
// Where DOLLAR_TO_CREDIT_MULTIPLIER = 1000000 (so $0.00001 = 1 credit)
// Example: 1 cent = (1 / 100) * 1000000 = 10000 credits
export const convertCentsToBillingCredits = (cents: number): number =>
(cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER;