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:
@@ -98,14 +98,15 @@ const StyledTimingValue = styled.span`
|
||||
|
||||
type TabType = 'timing' | 'details' | 'context';
|
||||
|
||||
const TimingRow = ({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
type TimingRowProps = {
|
||||
label: string;
|
||||
value: string | number | undefined;
|
||||
}) => {
|
||||
if (value === undefined) return null;
|
||||
};
|
||||
|
||||
const TimingRow = ({ label, value }: TimingRowProps) => {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledTimingRow>
|
||||
@@ -131,22 +132,26 @@ const formatTokenBreakdown = (
|
||||
completion?: number,
|
||||
) => {
|
||||
const formattedTotal = formatNumber(total);
|
||||
if (
|
||||
const hasValidBreakdown =
|
||||
prompt !== undefined &&
|
||||
completion !== undefined &&
|
||||
prompt > 0 &&
|
||||
completion > 0
|
||||
) {
|
||||
completion > 0;
|
||||
|
||||
if (hasValidBreakdown) {
|
||||
return `${formattedTotal} (${formatNumber(prompt)} → ${formatNumber(completion)})`;
|
||||
}
|
||||
|
||||
return formattedTotal;
|
||||
};
|
||||
|
||||
const TimingTab = ({
|
||||
debug,
|
||||
}: {
|
||||
debug: NonNullable<DataMessagePart['routing-status']['debug']>;
|
||||
}) => {
|
||||
type DebugInfo = NonNullable<DataMessagePart['routing-status']['debug']>;
|
||||
|
||||
type TimingTabProps = {
|
||||
debug: DebugInfo;
|
||||
};
|
||||
|
||||
const TimingTab = ({ debug }: TimingTabProps) => {
|
||||
const totalTime =
|
||||
debug.agentExecutionStartTimeMs !== undefined
|
||||
? `${debug.agentExecutionStartTimeMs + (debug.agentExecutionTimeMs || 0)}ms`
|
||||
@@ -228,13 +233,12 @@ const TimingTab = ({
|
||||
);
|
||||
};
|
||||
|
||||
const DetailsTab = ({
|
||||
debug,
|
||||
copyToClipboard,
|
||||
}: {
|
||||
debug: NonNullable<DataMessagePart['routing-status']['debug']>;
|
||||
type DetailsTabProps = {
|
||||
debug: DebugInfo;
|
||||
copyToClipboard: (value: string) => void;
|
||||
}) => {
|
||||
};
|
||||
|
||||
const DetailsTab = ({ debug, copyToClipboard }: DetailsTabProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const detailsData = {
|
||||
@@ -263,13 +267,12 @@ const DetailsTab = ({
|
||||
);
|
||||
};
|
||||
|
||||
const ContextTab = ({
|
||||
debug,
|
||||
copyToClipboard,
|
||||
}: {
|
||||
debug: NonNullable<DataMessagePart['routing-status']['debug']>;
|
||||
type ContextTabProps = {
|
||||
debug: DebugInfo;
|
||||
copyToClipboard: (value: string) => void;
|
||||
}) => {
|
||||
};
|
||||
|
||||
const ContextTab = ({ debug, copyToClipboard }: ContextTabProps) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
if (!debug.context) {
|
||||
@@ -298,15 +301,19 @@ const ContextTab = ({
|
||||
</StyledJsonTreeContainer>
|
||||
);
|
||||
} catch {
|
||||
return <StyledTimingLabel>{debug.context}</StyledTimingLabel>;
|
||||
return (
|
||||
<StyledTimingLabel>
|
||||
Failed to parse context: {debug.context}
|
||||
</StyledTimingLabel>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const RoutingDebugDisplay = ({
|
||||
debug,
|
||||
}: {
|
||||
debug: NonNullable<DataMessagePart['routing-status']['debug']>;
|
||||
}) => {
|
||||
type RoutingDebugDisplayProps = {
|
||||
debug: DebugInfo;
|
||||
};
|
||||
|
||||
export const RoutingDebugDisplay = ({ debug }: RoutingDebugDisplayProps) => {
|
||||
const theme = useTheme();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
-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;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-
|
||||
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
|
||||
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { type ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
|
||||
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';
|
||||
@@ -43,6 +44,16 @@ export interface AgentExecutionResult {
|
||||
usage: LanguageModelUsage;
|
||||
}
|
||||
|
||||
export interface StreamChatResponseResult {
|
||||
stream: ReturnType<typeof streamText>;
|
||||
timings: {
|
||||
contextBuildTimeMs: number;
|
||||
toolGenerationTimeMs: number;
|
||||
aiRequestPrepTimeMs: number;
|
||||
toolCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AgentExecutionService implements AgentExecutionContext {
|
||||
private readonly logger = new Logger(AgentExecutionService.name);
|
||||
@@ -68,6 +79,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
roleIds,
|
||||
excludeHandoffTools = false,
|
||||
userWorkspaceId,
|
||||
toolHints,
|
||||
}: {
|
||||
system: string;
|
||||
agent: AgentEntity | null;
|
||||
@@ -76,6 +88,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
roleIds?: string[];
|
||||
excludeHandoffTools?: boolean;
|
||||
userWorkspaceId?: string;
|
||||
toolHints?: ToolHints;
|
||||
}) {
|
||||
try {
|
||||
if (agent) {
|
||||
@@ -98,6 +111,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
actorContext,
|
||||
roleIds,
|
||||
userWorkspaceId,
|
||||
toolHints,
|
||||
);
|
||||
|
||||
let handoffTools = {};
|
||||
@@ -169,6 +183,9 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
}
|
||||
}
|
||||
|
||||
// Fetches and formats record data to provide context for AI agents
|
||||
// Respects permissions and field restrictions based on user role
|
||||
// Returns a JSON string with record data and workspace URLs
|
||||
async getContextForSystemPrompt(
|
||||
workspace: WorkspaceEntity,
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
|
||||
@@ -272,12 +289,14 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
agentId,
|
||||
messages,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
toolHints,
|
||||
}: {
|
||||
workspace: WorkspaceEntity;
|
||||
userWorkspaceId: string;
|
||||
agentId: string;
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
|
||||
toolHints?: ToolHints;
|
||||
}): Promise<{
|
||||
stream: ReturnType<typeof streamText>;
|
||||
timings: {
|
||||
@@ -335,6 +354,7 @@ export class AgentExecutionService implements AgentExecutionContext {
|
||||
actorContext,
|
||||
roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])],
|
||||
userWorkspaceId,
|
||||
toolHints,
|
||||
});
|
||||
|
||||
const aiRequestPrepTime = Date.now() - aiRequestPrepStart;
|
||||
|
||||
+49
-38
@@ -27,6 +27,12 @@ import {
|
||||
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
|
||||
import { AiRouterService } from 'src/engine/metadata-modules/ai-router/ai-router.service';
|
||||
|
||||
export type TokenUsage = {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type StreamAgentChatOptions = {
|
||||
threadId: string;
|
||||
userWorkspaceId: string;
|
||||
@@ -87,17 +93,18 @@ export class AgentStreamingService {
|
||||
});
|
||||
|
||||
const routingStart = Date.now();
|
||||
const includeDebugInfo = true;
|
||||
const routeResult = await this.aiRouterService.routeMessage(
|
||||
{
|
||||
messages,
|
||||
workspaceId: workspace.id,
|
||||
routerModel: workspace.routerModel,
|
||||
},
|
||||
true,
|
||||
includeDebugInfo,
|
||||
);
|
||||
|
||||
const routingTime = Date.now() - routingStart;
|
||||
const { agent, debugInfo } = routeResult;
|
||||
const { agent, debugInfo, toolHints } = routeResult;
|
||||
|
||||
if (!agent) {
|
||||
writer.write({
|
||||
@@ -154,6 +161,7 @@ export class AgentStreamingService {
|
||||
userWorkspaceId,
|
||||
messages,
|
||||
recordIdsByObjectMetadataNameSingular,
|
||||
toolHints,
|
||||
});
|
||||
|
||||
const routedStatusPart = {
|
||||
@@ -199,41 +207,7 @@ export class AgentStreamingService {
|
||||
part.type.startsWith('tool-'),
|
||||
).length;
|
||||
|
||||
let tokenUsage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
} | null = null;
|
||||
|
||||
try {
|
||||
const usage = await result.usage;
|
||||
|
||||
const usageWithTokens = usage as {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
tokenUsage = {
|
||||
promptTokens:
|
||||
usageWithTokens.inputTokens ??
|
||||
usageWithTokens.promptTokens ??
|
||||
0,
|
||||
completionTokens:
|
||||
usageWithTokens.outputTokens ??
|
||||
usageWithTokens.completionTokens ??
|
||||
0,
|
||||
totalTokens: usageWithTokens.totalTokens ?? 0,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Agent execution usage: ${tokenUsage.promptTokens} prompt + ${tokenUsage.completionTokens} completion = ${tokenUsage.totalTokens} total tokens`,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to get token usage:', error);
|
||||
}
|
||||
const tokenUsage = await this.extractTokenUsage(result.usage);
|
||||
|
||||
const agentExecutionTime = Date.now() - agentExecutionStart;
|
||||
|
||||
@@ -325,8 +299,45 @@ export class AgentStreamingService {
|
||||
|
||||
pipeUIMessageStreamToResponse({ stream, response });
|
||||
} catch (error) {
|
||||
this.logger.error(error.message);
|
||||
this.logger.error(
|
||||
'Failed to stream agent chat:',
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
response.end();
|
||||
}
|
||||
}
|
||||
|
||||
private async extractTokenUsage(
|
||||
usagePromise: Promise<unknown>,
|
||||
): Promise<TokenUsage | null> {
|
||||
try {
|
||||
const usage = await usagePromise;
|
||||
|
||||
const usageWithTokens = usage as {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
totalTokens?: number;
|
||||
};
|
||||
|
||||
const tokenUsage = {
|
||||
promptTokens:
|
||||
usageWithTokens.inputTokens ?? usageWithTokens.promptTokens ?? 0,
|
||||
completionTokens:
|
||||
usageWithTokens.outputTokens ?? usageWithTokens.completionTokens ?? 0,
|
||||
totalTokens: usageWithTokens.totalTokens ?? 0,
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Agent execution usage: ${tokenUsage.promptTokens} prompt + ${tokenUsage.completionTokens} completion = ${tokenUsage.totalTokens} total tokens`,
|
||||
);
|
||||
|
||||
return tokenUsage;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to get token usage:', error);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-ada
|
||||
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
|
||||
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
|
||||
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
|
||||
import { type ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
|
||||
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
|
||||
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
|
||||
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
|
||||
@@ -37,6 +38,7 @@ export class AgentToolGeneratorService {
|
||||
actorContext?: ActorMetadata,
|
||||
roleIds?: string[],
|
||||
userWorkspaceId?: string,
|
||||
toolHints?: ToolHints,
|
||||
): Promise<ToolSet> {
|
||||
let tools: ToolSet = {};
|
||||
|
||||
@@ -78,6 +80,7 @@ export class AgentToolGeneratorService {
|
||||
workspaceId,
|
||||
actorContext,
|
||||
userWorkspaceId,
|
||||
toolHints,
|
||||
);
|
||||
|
||||
tools = { ...tools, ...databaseTools };
|
||||
|
||||
@@ -19,6 +19,8 @@ import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metada
|
||||
import { DATA_MANIPULATOR_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/data-manipulator-agent';
|
||||
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
|
||||
|
||||
import { type ToolHints } from './types/tool-hints.interface';
|
||||
|
||||
export interface AiRouterContext {
|
||||
messages: UIMessage<unknown, UIDataTypes, UITools>[];
|
||||
workspaceId: string;
|
||||
@@ -27,6 +29,7 @@ export interface AiRouterContext {
|
||||
|
||||
export interface AiRouterResult {
|
||||
agent: AgentEntity | null;
|
||||
toolHints?: ToolHints;
|
||||
debugInfo?: {
|
||||
availableAgents: Array<{ id: string; label: string }>;
|
||||
routerModel: string;
|
||||
@@ -47,6 +50,9 @@ export class AiRouterService {
|
||||
private readonly objectMetadataService: ObjectMetadataService,
|
||||
) {}
|
||||
|
||||
// Routes a user message to the most appropriate agent
|
||||
// Uses AI to analyze the conversation and select the best agent
|
||||
// Returns the selected agent along with tool hints for optimization
|
||||
async routeMessage(
|
||||
context: AiRouterContext,
|
||||
includeDebugInfo = false,
|
||||
@@ -105,18 +111,42 @@ export class AiRouterService {
|
||||
currentMessage,
|
||||
);
|
||||
|
||||
const agentIds = availableAgents.map((agent) => agent.id);
|
||||
|
||||
if (agentIds.length === 0) {
|
||||
throw new Error('No agent IDs available for routing schema');
|
||||
}
|
||||
|
||||
const routerDecisionSchema = z.object({
|
||||
agentId: z
|
||||
.enum(availableAgents.map((agent) => agent.id))
|
||||
.enum([agentIds[0], ...agentIds.slice(1)])
|
||||
.describe('The ID of the most suitable agent to handle this message'),
|
||||
toolHints: z
|
||||
.object({
|
||||
relevantObjects: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
'Names of the specific objects mentioned in the query (e.g., "person", "company")',
|
||||
),
|
||||
operations: z
|
||||
.array(z.enum(['find', 'create', 'update', 'delete']))
|
||||
.optional()
|
||||
.describe(
|
||||
'Specific operations needed: find (search/query), create (new records), update (modify), delete (remove)',
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ROUTER_TEMPERATURE = 0.1; // Low temperature for deterministic routing
|
||||
|
||||
const result = await generateObject({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
schema: routerDecisionSchema,
|
||||
temperature: 0.1,
|
||||
temperature: ROUTER_TEMPERATURE,
|
||||
experimental_telemetry: AI_TELEMETRY_CONFIG,
|
||||
});
|
||||
|
||||
@@ -148,7 +178,11 @@ export class AiRouterService {
|
||||
}
|
||||
}
|
||||
|
||||
return { agent: selectedAgent ?? null, debugInfo };
|
||||
return {
|
||||
agent: selectedAgent ?? null,
|
||||
toolHints: result.object.toolHints,
|
||||
debugInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'Routing to agent failed, falling back to Helper agent:',
|
||||
@@ -260,7 +294,25 @@ ${workspaceObjectsList}`;
|
||||
Available agents:
|
||||
${agentDescriptions}
|
||||
|
||||
Your task is to analyze the user's message and conversation history, then select the most appropriate agent to handle it. Choose the agent whose description and capabilities best match the user's request.`;
|
||||
Your task is to:
|
||||
1. Select the most appropriate agent
|
||||
2. Identify specific objects mentioned in the query (if any)
|
||||
3. Determine which operations are needed
|
||||
|
||||
For toolHints:
|
||||
- relevantObjects: Extract object names the user is asking about (e.g., if asking about "companies and people", return ["company", "person"])
|
||||
- operations: Array of needed operations from: ["find", "create", "update", "delete"]
|
||||
- "find": for searching, querying, or reading data
|
||||
- "create": for creating new records
|
||||
- "update": for modifying existing records
|
||||
- "delete": for removing records
|
||||
|
||||
Examples:
|
||||
- "Show me all companies" → operations: ["find"]
|
||||
- "Create a task for John" → operations: ["create"]
|
||||
- "Update the company name" → operations: ["find", "update"]
|
||||
|
||||
This helps optimize the agent's tool context by only loading relevant tools.`;
|
||||
}
|
||||
|
||||
private buildRouterUserPrompt(
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
export type ToolOperation = 'find' | 'create' | 'update' | 'delete';
|
||||
|
||||
export interface ToolHints {
|
||||
// Object names (singular or plural) that are relevant to the query
|
||||
relevantObjects?: string[];
|
||||
// Specific CRUD operations needed for the query
|
||||
operations?: ToolOperation[];
|
||||
}
|
||||
+4
-5
@@ -55,11 +55,10 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(7);
|
||||
expect(Object.keys(tools)).toHaveLength(6);
|
||||
expect(Object.keys(tools)).toContain('create_testObject');
|
||||
expect(Object.keys(tools)).toContain('update_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_testObject');
|
||||
expect(Object.keys(tools)).toContain('soft_delete_many_testObject');
|
||||
expect(Object.keys(tools)).toContain('http_request');
|
||||
@@ -100,9 +99,9 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
);
|
||||
|
||||
expect(tools).toBeDefined();
|
||||
expect(Object.keys(tools)).toHaveLength(3);
|
||||
expect(Object.keys(tools)).toHaveLength(2);
|
||||
expect(Object.keys(tools)).toContain('find_testObject');
|
||||
expect(Object.keys(tools)).toContain('find_one_testObject');
|
||||
expect(Object.keys(tools)).toContain('http_request');
|
||||
expect(Object.keys(tools)).not.toContain('create_testObject');
|
||||
expect(Object.keys(tools)).not.toContain('update_testObject');
|
||||
});
|
||||
@@ -157,7 +156,7 @@ describe('AgentToolGeneratorService Integration', () => {
|
||||
[context.testRoleId],
|
||||
);
|
||||
|
||||
expect(Object.keys(tools)).toHaveLength(7);
|
||||
expect(Object.keys(tools)).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user