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:
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-8
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user