Show tool execution messages in AI agent chat (#13117)

https://github.com/user-attachments/assets/c0a42726-50ac-496e-a993-9d6076a84a6a

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-07-10 11:15:05 +05:30
committed by GitHub
parent e6cdae5c27
commit 8310b4ff01
62 changed files with 1304 additions and 227 deletions
@@ -0,0 +1,55 @@
import { getAIModelsWithAuto } from 'src/engine/core-modules/ai/utils/get-ai-models-with-auto.util';
import { getDefaultModelConfig } from 'src/engine/core-modules/ai/utils/get-default-model-config.util';
import { AI_MODELS, DEFAULT_MODEL_ID, ModelProvider } from './ai-models.const';
describe('AI_MODELS', () => {
it('should contain all expected models', () => {
expect(AI_MODELS).toHaveLength(6);
expect(AI_MODELS.map((model) => model.modelId)).toEqual([
'gpt-4o',
'gpt-4o-mini',
'gpt-4-turbo',
'claude-opus-4-20250514',
'claude-sonnet-4-20250514',
'claude-3-5-haiku-20241022',
]);
});
it('should have the default model as the first model', () => {
const DEFAULT_MODEL = AI_MODELS.find(
(model) => model.modelId === DEFAULT_MODEL_ID,
);
expect(DEFAULT_MODEL).toBeDefined();
expect(DEFAULT_MODEL?.modelId).toBe(DEFAULT_MODEL_ID);
});
});
describe('getAIModelsWithAuto', () => {
it('should return AI_MODELS with auto model prepended', () => {
const ORIGINAL_MODELS = AI_MODELS;
const MODELS_WITH_AUTO = getAIModelsWithAuto();
expect(MODELS_WITH_AUTO).toHaveLength(ORIGINAL_MODELS.length + 1);
expect(MODELS_WITH_AUTO[0].modelId).toBe('auto');
expect(MODELS_WITH_AUTO[0].label).toBe('Auto');
expect(MODELS_WITH_AUTO[0].provider).toBe(ModelProvider.NONE);
// Check that the rest of the models are the same
expect(MODELS_WITH_AUTO.slice(1)).toEqual(ORIGINAL_MODELS);
});
it('should have auto model with default model costs', () => {
const MODELS_WITH_AUTO = getAIModelsWithAuto();
const AUTO_MODEL = MODELS_WITH_AUTO[0];
const DEFAULT_MODEL = getDefaultModelConfig();
expect(AUTO_MODEL.inputCostPer1kTokensInCents).toBe(
DEFAULT_MODEL.inputCostPer1kTokensInCents,
);
expect(AUTO_MODEL.outputCostPer1kTokensInCents).toBe(
DEFAULT_MODEL.outputCostPer1kTokensInCents,
);
});
});
@@ -1,9 +1,11 @@
export enum ModelProvider {
NONE = 'none',
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
}
export type ModelId =
| 'auto'
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4-turbo'
@@ -11,6 +13,8 @@ export type ModelId =
| 'claude-sonnet-4-20250514'
| 'claude-3-5-haiku-20241022';
export const DEFAULT_MODEL_ID: ModelId = 'gpt-4o';
export interface AIModelConfig {
modelId: ModelId;
label: string;
@@ -2,7 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id.util';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
@@ -1,19 +1,19 @@
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isDefined } from 'twenty-shared/utils';
import { ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response';
import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/constants/admin-role-label.constants';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
@Injectable()
export class McpService {
@@ -1,30 +1,31 @@
import { Injectable } from '@nestjs/common';
import { ToolSet } from 'ai';
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
ILike,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import { ToolSet } from 'ai';
import { z } from 'zod';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { ObjectMetadataService } from 'src/engine/metadata-modules/object-metadata/object-metadata.service';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import {
generateBulkDeleteToolSchema,
generateFindOneToolSchema,
generateFindToolSchema,
generateSoftDeleteToolSchema,
getRecordInputSchema,
} from 'src/engine/metadata-modules/agent/utils/agent-tool-schema.utils';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
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';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
@Injectable()
export class ToolService {
@@ -72,7 +73,7 @@ export class ToolService {
execute: async (parameters) => {
return this.createRecord(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -85,7 +86,7 @@ export class ToolService {
execute: async (parameters) => {
return this.updateRecord(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -100,7 +101,7 @@ export class ToolService {
execute: async (parameters) => {
return this.findRecords(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -109,15 +110,11 @@ 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.`,
parameters: z.object({
id: z
.string()
.describe('The unique UUID of the record to retrieve'),
}),
parameters: generateFindOneToolSchema(),
execute: async (parameters) => {
return this.findOneRecord(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -128,15 +125,11 @@ export class ToolService {
if (objectPermission.canSoftDelete) {
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.`,
parameters: z.object({
id: z
.string()
.describe('The unique UUID of the record to soft delete'),
}),
parameters: generateSoftDeleteToolSchema(),
execute: async (parameters) => {
return this.softDeleteRecord(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -149,7 +142,7 @@ export class ToolService {
execute: async (parameters) => {
return this.softDeleteManyRecords(
objectMetadata.nameSingular,
parameters,
parameters.input,
workspaceId,
roleId,
);
@@ -4,4 +4,5 @@
* @param cents - Cost in cents (real cost)
* @returns Cost in credits (end-user cost)
*/
export const convertCentsToCredits = (cents: number): number => cents * 10;
export const convertCentsToBillingCredits = (cents: number): number =>
cents * 10;
@@ -1,9 +0,0 @@
import {
AI_MODELS,
AIModelConfig,
ModelId,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
export const getAIModelById = (modelId: ModelId): AIModelConfig | undefined => {
return AI_MODELS.find((model) => model.modelId === modelId);
};
@@ -0,0 +1,7 @@
import { AIModelConfig } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getEffectiveModelConfig } from './get-effective-model-config.util';
export const getAIModelById = (modelId: string): AIModelConfig => {
return getEffectiveModelConfig(modelId);
};
@@ -0,0 +1,35 @@
import {
AI_MODELS,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getAIModelsWithAuto } from './get-ai-models-with-auto.util';
import { getDefaultModelConfig } from './get-default-model-config.util';
describe('getAIModelsWithAuto', () => {
it('should return AI_MODELS with auto model prepended', () => {
const ORIGINAL_MODELS = AI_MODELS;
const MODELS_WITH_AUTO = getAIModelsWithAuto();
expect(MODELS_WITH_AUTO).toHaveLength(ORIGINAL_MODELS.length + 1);
expect(MODELS_WITH_AUTO[0].modelId).toBe('auto');
expect(MODELS_WITH_AUTO[0].label).toBe('Auto');
expect(MODELS_WITH_AUTO[0].provider).toBe(ModelProvider.NONE);
// Check that the rest of the models are the same
expect(MODELS_WITH_AUTO.slice(1)).toEqual(ORIGINAL_MODELS);
});
it('should have auto model with default model costs', () => {
const MODELS_WITH_AUTO = getAIModelsWithAuto();
const AUTO_MODEL = MODELS_WITH_AUTO[0];
const DEFAULT_MODEL = getDefaultModelConfig();
expect(AUTO_MODEL.inputCostPer1kTokensInCents).toBe(
DEFAULT_MODEL.inputCostPer1kTokensInCents,
);
expect(AUTO_MODEL.outputCostPer1kTokensInCents).toBe(
DEFAULT_MODEL.outputCostPer1kTokensInCents,
);
});
});
@@ -0,0 +1,22 @@
import {
AI_MODELS,
AIModelConfig,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getDefaultModelConfig } from './get-default-model-config.util';
export const getAIModelsWithAuto = (): AIModelConfig[] => {
return [
{
modelId: 'auto',
label: 'Auto',
provider: ModelProvider.NONE,
inputCostPer1kTokensInCents:
getDefaultModelConfig().inputCostPer1kTokensInCents,
outputCostPer1kTokensInCents:
getDefaultModelConfig().outputCostPer1kTokensInCents,
},
...AI_MODELS,
];
};
@@ -0,0 +1,29 @@
import {
AI_MODELS,
DEFAULT_MODEL_ID,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getDefaultModelConfig } from './get-default-model-config.util';
describe('getDefaultModelConfig', () => {
it('should return the configuration for the default model', () => {
const result = getDefaultModelConfig();
expect(result).toBeDefined();
expect(result.modelId).toBe(DEFAULT_MODEL_ID);
expect(result.provider).toBe(ModelProvider.OPENAI);
});
it('should throw an error if default model is not found', () => {
const originalFind = AI_MODELS.find;
AI_MODELS.find = jest.fn().mockReturnValue(undefined);
expect(() => getDefaultModelConfig()).toThrow(
`Default model with ID ${DEFAULT_MODEL_ID} not found`,
);
AI_MODELS.find = originalFind;
});
});
@@ -0,0 +1,17 @@
import {
AI_MODELS,
AIModelConfig,
DEFAULT_MODEL_ID,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
export const getDefaultModelConfig = (): AIModelConfig => {
const defaultModel = AI_MODELS.find(
(model) => model.modelId === DEFAULT_MODEL_ID,
);
if (!defaultModel) {
throw new Error(`Default model with ID ${DEFAULT_MODEL_ID} not found`);
}
return defaultModel;
};
@@ -0,0 +1,30 @@
import {
DEFAULT_MODEL_ID,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getEffectiveModelConfig } from './get-effective-model-config.util';
describe('getEffectiveModelConfig', () => {
it('should return default model config when modelId is "auto"', () => {
const result = getEffectiveModelConfig('auto');
expect(result).toBeDefined();
expect(result.modelId).toBe(DEFAULT_MODEL_ID);
expect(result.provider).toBe(ModelProvider.OPENAI);
});
it('should return the correct model config for a specific model', () => {
const result = getEffectiveModelConfig('gpt-4o');
expect(result).toBeDefined();
expect(result.modelId).toBe('gpt-4o');
expect(result.provider).toBe(ModelProvider.OPENAI);
});
it('should throw an error for non-existent model', () => {
expect(() => getEffectiveModelConfig('non-existent-model' as any)).toThrow(
`Model with ID non-existent-model not found`,
);
});
});
@@ -0,0 +1,20 @@
import {
AI_MODELS,
AIModelConfig,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getDefaultModelConfig } from './get-default-model-config.util';
export const getEffectiveModelConfig = (modelId: string): AIModelConfig => {
if (modelId === 'auto') {
return getDefaultModelConfig();
}
const model = AI_MODELS.find((model) => model.modelId === modelId);
if (!model) {
throw new Error(`Model with ID ${modelId} not found`);
}
return model;
};