feat: Add AI Agent workflow action node (#12650)

https://github.com/user-attachments/assets/8593e488-cb00-4fd2-b903-5ba5766e0254

---------

Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Baptiste Devessier <baptiste@devessier.fr>
Co-authored-by: Joseph Chiang <josephj6802@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Guillim <guillim@users.noreply.github.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Naifer <161821705+omarNaifer12@users.noreply.github.com>
Co-authored-by: prastoin <paul@twenty.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@twenty.com>
Co-authored-by: Thomas Trompette <thomas.trompette@sfr.fr>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Ajay A Adsule <103304466+AjayAdsule@users.noreply.github.com>
Co-authored-by: bosiraphael <raphael.bosi@gmail.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
Co-authored-by: Marty <91310557+real-marty@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
Co-authored-by: Weiko <corentin@twenty.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: nitin <142569587+ehconitin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-06-23 01:12:04 +05:30
committed by GitHub
parent 22e126869c
commit 65df511179
75 changed files with 2268 additions and 30 deletions

View File

@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateAgentTable1747401483136 implements MigrationInterface {
name = 'CreateAgentTable1747401483136';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."agent" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"name" character varying NOT NULL,
"description" character varying,
"prompt" text NOT NULL,
"modelId" character varying NOT NULL,
"responseFormat" jsonb,
"workspaceId" uuid NOT NULL,
"createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"deletedAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_agent" PRIMARY KEY ("id")
)`,
);
await queryRunner.query(
`CREATE INDEX "IDX_AGENT_ID_DELETED_AT" ON "core"."agent" ("id", "deletedAt")`,
);
await queryRunner.query(
`ALTER TABLE "core"."agent" ADD CONSTRAINT "FK_c4cb56621768a4a325dd772bbe1" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."agent" DROP CONSTRAINT "FK_c4cb56621768a4a325dd772bbe1"`,
);
await queryRunner.query(`DROP INDEX "core"."IDX_AGENT_ID_DELETED_AT"`);
await queryRunner.query(`DROP TABLE "core"."agent"`);
}
}

View File

@ -20,6 +20,8 @@ import { TwoFactorMethod } from 'src/engine/core-modules/two-factor-method/two-f
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { User } from 'src/engine/core-modules/user/user.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
@Injectable()
export class TypeORMService implements OnModuleInit, OnModuleDestroy {
private mainDataSource: DataSource;
@ -48,6 +50,7 @@ export class TypeORMService implements OnModuleInit, OnModuleDestroy {
WorkspaceSSOIdentityProvider,
ApprovedAccessDomain,
TwoFactorMethod,
AgentEntity,
],
metadataTableName: '_typeorm_generated_columns_and_materialized_views',
ssl: twentyConfigService.get('PG_SSL_ALLOW_SELF_SIGNED')

View File

@ -9,6 +9,7 @@ import { AI_DRIVER } from 'src/engine/core-modules/ai/ai.constants';
import { AiService } from 'src/engine/core-modules/ai/ai.service';
import { AiController } from 'src/engine/core-modules/ai/controllers/ai.controller';
import { OpenAIDriver } from 'src/engine/core-modules/ai/drivers/openai.driver';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
@Global()
@ -33,8 +34,8 @@ export class AiModule {
module: AiModule,
imports: [FeatureFlagModule],
controllers: [AiController],
providers: [AiService, provider],
exports: [AiService],
providers: [AiService, AIBillingService, provider],
exports: [AiService, AIBillingService],
};
}
}

View File

@ -0,0 +1,65 @@
export enum ModelProvider {
OPENAI = 'openai',
ANTHROPIC = 'anthropic',
}
export type ModelId =
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4-turbo'
| 'claude-opus-4-20250514'
| 'claude-sonnet-4-20250514'
| 'claude-3-5-haiku-20241022';
export interface AIModelConfig {
modelId: ModelId;
label: string;
provider: ModelProvider;
inputCostPer1kTokensInCents: number;
outputCostPer1kTokensInCents: number;
}
export const AI_MODELS: AIModelConfig[] = [
{
modelId: 'gpt-4o',
label: 'GPT-4o',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.25,
outputCostPer1kTokensInCents: 1.0,
},
{
modelId: 'gpt-4o-mini',
label: 'GPT-4o Mini',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 0.015,
outputCostPer1kTokensInCents: 0.06,
},
{
modelId: 'gpt-4-turbo',
label: 'GPT-4 Turbo',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCents: 1.0,
outputCostPer1kTokensInCents: 3.0,
},
{
modelId: 'claude-opus-4-20250514',
label: 'Claude Opus 4',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 1.5,
outputCostPer1kTokensInCents: 7.5,
},
{
modelId: 'claude-sonnet-4-20250514',
label: 'Claude Sonnet 4',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.3,
outputCostPer1kTokensInCents: 1.5,
},
{
modelId: 'claude-3-5-haiku-20241022',
label: 'Claude Haiku 3.5',
provider: ModelProvider.ANTHROPIC,
inputCostPer1kTokensInCents: 0.08,
outputCostPer1kTokensInCents: 0.4,
},
];

View File

@ -0,0 +1,2 @@
// Configuration: $0.001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000; // 1 / 0.001 = 1000 credits per dollar

View File

@ -0,0 +1,90 @@
import { Test, TestingModule } from '@nestjs/testing';
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 { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { AIBillingService } from './ai-billing.service';
describe('AIBillingService', () => {
let service: AIBillingService;
let mockWorkspaceEventEmitter: jest.Mocked<WorkspaceEventEmitter>;
const mockTokenUsage = {
promptTokens: 1000,
completionTokens: 500,
totalTokens: 1500,
};
beforeEach(async () => {
const mockEventEmitterMethods = {
emitCustomBatchEvent: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
AIBillingService,
{
provide: WorkspaceEventEmitter,
useValue: mockEventEmitterMethods,
},
],
}).compile();
service = module.get<AIBillingService>(AIBillingService);
mockWorkspaceEventEmitter = module.get(WorkspaceEventEmitter);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('calculateCost', () => {
it('should calculate cost correctly for valid model and token usage', async () => {
const costInCents = await service.calculateCost('gpt-4o', mockTokenUsage);
// Expected: (1000/1000 * 0.25) + (500/1000 * 1.0) = 0.25 + 0.5 = 0.75 cents
expect(costInCents).toBe(0.75);
});
it('should calculate cost correctly with different token usage', async () => {
const differentTokenUsage = {
promptTokens: 2000,
completionTokens: 1000,
totalTokens: 3000,
};
const costInCents = await service.calculateCost(
'gpt-4o',
differentTokenUsage,
);
// Expected: (2000/1000 * 0.25) + (1000/1000 * 1.0) = 0.5 + 1.0 = 1.5 cents
expect(costInCents).toBe(1.5);
});
});
describe('calculateAndBillUsage', () => {
it('should calculate cost and emit billing event when model exists', async () => {
await service.calculateAndBillUsage(
'gpt-4o',
mockTokenUsage,
'workspace-1',
);
// Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8
expect(
mockWorkspaceEventEmitter.emitCustomBatchEvent,
).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 8,
},
],
'workspace-1',
);
});
});
});

View File

@ -0,0 +1,72 @@
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 { 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';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
export interface TokenUsage {
promptTokens: number;
completionTokens: number;
totalTokens: number;
}
@Injectable()
export class AIBillingService {
private readonly logger = new Logger(AIBillingService.name);
constructor(private readonly workspaceEventEmitter: WorkspaceEventEmitter) {}
async calculateCost(modelId: ModelId, usage: TokenUsage): Promise<number> {
const model = getAIModelById(modelId);
if (!model) {
throw new Error(`AI model with id ${modelId} not found`);
}
const inputCost =
(usage.promptTokens / 1000) * model.inputCostPer1kTokensInCents;
const outputCost =
(usage.completionTokens / 1000) * model.outputCostPer1kTokensInCents;
const totalCost = inputCost + outputCost;
this.logger.log(
`Calculated cost for model ${modelId}: ${totalCost} cents (input: ${inputCost}, output: ${outputCost})`,
);
return totalCost;
}
async calculateAndBillUsage(
modelId: ModelId,
usage: TokenUsage,
workspaceId: string,
): Promise<void> {
const costInCents = await this.calculateCost(modelId, usage);
const costInDollars = costInCents / 100;
const creditsUsed = Math.round(costInDollars * DOLLAR_TO_CREDIT_MULTIPLIER);
this.sendAiTokenUsageEvent(workspaceId, creditsUsed);
}
private sendAiTokenUsageEvent(
workspaceId: string,
creditsUsed: number,
): void {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: creditsUsed,
},
],
workspaceId,
);
}
}

View File

@ -0,0 +1,7 @@
/**
* Converts cost in cents to cost in credits
* Formula: credits = cents / 100 * 1000 = cents * 10
* @param cents - Cost in cents (real cost)
* @returns Cost in credits (end-user cost)
*/
export const convertCentsToCredits = (cents: number): number => cents * 10;

View File

@ -0,0 +1,9 @@
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);
};

View File

@ -3,6 +3,7 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingAddWorkflowSubscriptionItemCommand } from 'src/engine/core-modules/billing/commands/billing-add-workflow-subscription-item.command';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
@ -41,6 +42,7 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
DomainManagerModule,
MessageQueueModule,
PermissionsModule,
AiModule,
TypeOrmModule.forFeature(
[
BillingSubscription,

View File

@ -2,6 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { ClientConfigController } from './client-config.controller';
@ -44,6 +48,15 @@ describe('ClientConfigController', () => {
},
],
},
aiModels: [
{
modelId: 'gpt-4o' as ModelId,
label: 'GPT-4o',
provider: ModelProvider.OPENAI,
inputCostPer1kTokensInCredits: 2.5,
outputCostPer1kTokensInCredits: 10.0,
},
],
authProviders: {
google: true,
magicLink: false,
@ -92,8 +105,8 @@ describe('ClientConfigController', () => {
const result = await controller.getClientConfig();
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
expect(result).toEqual(mockClientConfig);
expect(clientConfigService.getClientConfig).toHaveBeenCalled();
});
});
});

View File

@ -2,6 +2,10 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@ -11,6 +15,28 @@ registerEnumType(FeatureFlagKey, {
name: 'FeatureFlagKey',
});
registerEnumType(ModelProvider, {
name: 'ModelProvider',
});
@ObjectType()
export class ClientAIModelConfig {
@Field(() => String)
modelId: ModelId;
@Field(() => String)
label: string;
@Field(() => ModelProvider)
provider: ModelProvider;
@Field(() => Number)
inputCostPer1kTokensInCredits: number;
@Field(() => Number)
outputCostPer1kTokensInCredits: number;
}
@ObjectType()
class Billing {
@Field(() => Boolean)
@ -88,6 +114,9 @@ export class ClientConfig {
@Field(() => Billing, { nullable: false })
billing: Billing;
@Field(() => [ClientAIModelConfig])
aiModels: ClientAIModelConfig[];
@Field(() => Boolean)
signInPrefilled: boolean;

View File

@ -9,6 +9,10 @@ import { DomainManagerService } from 'src/engine/core-modules/domain-manager/ser
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
jest.mock('src/engine/core-modules/ai/constants/ai-models.const', () => ({
AI_MODELS: [],
}));
describe('ClientConfigService', () => {
let service: ClientConfigService;
let twentyConfigService: TwentyConfigService;
@ -107,6 +111,7 @@ describe('ClientConfigService', () => {
},
],
},
aiModels: [],
authProviders: {
google: true,
magicLink: false,
@ -164,6 +169,7 @@ describe('ClientConfigService', () => {
expect(result.debugMode).toBe(false);
expect(result.canManageFeatureFlags).toBe(false);
expect(result.aiModels).toEqual([]);
});
it('should handle missing captcha driver', async () => {
@ -180,6 +186,7 @@ describe('ClientConfigService', () => {
expect(result.captcha.provider).toBeUndefined();
expect(result.captcha.siteKey).toBe('site-key');
expect(result.aiModels).toEqual([]);
});
it('should handle missing support driver', async () => {
@ -194,6 +201,7 @@ describe('ClientConfigService', () => {
const result = await service.getClientConfig();
expect(result.support.supportDriver).toBe(SupportDriver.NONE);
expect(result.aiModels).toEqual([]);
});
it('should handle billing enabled with feature flags', async () => {

View File

@ -3,7 +3,15 @@ import { Injectable } from '@nestjs/common';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { ClientConfig } from 'src/engine/core-modules/client-config/client-config.entity';
import {
AI_MODELS,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { convertCentsToCredits } from 'src/engine/core-modules/ai/utils/ai-cost.utils';
import {
ClientAIModelConfig,
ClientConfig,
} from 'src/engine/core-modules/client-config/client-config.entity';
import { DomainManagerService } from 'src/engine/core-modules/domain-manager/services/domain-manager.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@ -18,6 +26,32 @@ export class ClientConfigService {
async getClientConfig(): Promise<ClientConfig> {
const captchaProvider = this.twentyConfigService.get('CAPTCHA_DRIVER');
const supportDriver = this.twentyConfigService.get('SUPPORT_DRIVER');
const openaiApiKey = this.twentyConfigService.get('OPENAI_API_KEY');
const anthropicApiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
const aiModels = AI_MODELS.reduce<ClientAIModelConfig[]>((acc, model) => {
const isAvailable =
(model.provider === ModelProvider.OPENAI && openaiApiKey) ||
(model.provider === ModelProvider.ANTHROPIC && anthropicApiKey);
if (!isAvailable) {
return acc;
}
acc.push({
modelId: model.modelId,
label: model.label,
provider: model.provider,
inputCostPer1kTokensInCredits: convertCentsToCredits(
model.inputCostPer1kTokensInCents,
),
outputCostPer1kTokensInCredits: convertCentsToCredits(
model.outputCostPer1kTokensInCents,
),
});
return acc;
}, []);
const clientConfig: ClientConfig = {
billing: {
@ -38,6 +72,7 @@ export class ClientConfigService {
},
],
},
aiModels,
authProviders: {
google: this.twentyConfigService.get('AUTH_GOOGLE_ENABLED'),
magicLink: false,

View File

@ -984,6 +984,14 @@ export class ConfigVariables {
})
OPENAI_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.LLM,
isSensitive: true,
description: 'API key for Anthropic integration',
type: ConfigVariableType.STRING,
})
ANTHROPIC_API_KEY: string;
@ConfigVariablesMetadata({
group: ConfigVariablesGroup.ServerConfig,
description: 'Enable or disable multi-workspace support',

View File

@ -1,6 +1,8 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql';
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 { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { DeleteWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/delete-workflow-version-step-input.dto';
import { SubmitFormStepInput } from 'src/engine/core-modules/workflow/dtos/submit-form-step-input.dto';
@ -15,6 +17,7 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service';
import { WorkflowActionType } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
import { WorkflowRunWorkspaceService } from 'src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service';
@Resolver()
@ -28,6 +31,7 @@ export class WorkflowStepResolver {
constructor(
private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
private readonly featureFlagService: FeatureFlagService,
) {}
@Mutation(() => WorkflowActionDTO)
@ -36,6 +40,19 @@ export class WorkflowStepResolver {
@Args('input')
input: CreateWorkflowVersionStepInput,
): Promise<WorkflowActionDTO> {
if (input.stepType === WorkflowActionType.AI_AGENT) {
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_AI_ENABLED,
workspaceId,
);
if (!isAiEnabled) {
throw new Error(
'AI features are not available in your current workspace. Please contact support to enable them.',
);
}
}
return this.workflowVersionStepWorkspaceService.createWorkflowVersionStep({
workspaceId,
input,

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { WorkflowTriggerController } from 'src/engine/core-modules/workflow/controllers/workflow-trigger.controller';
import { WorkflowBuilderResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-builder.resolver';
import { WorkflowStepResolver } from 'src/engine/core-modules/workflow/resolvers/workflow-step.resolver';
@ -14,6 +15,7 @@ import { WorkflowTriggerModule } from 'src/modules/workflow/workflow-trigger/wor
@Module({
imports: [
FeatureFlagModule,
WorkflowTriggerModule,
WorkflowBuilderModule,
WorkflowCommonModule,

View File

@ -22,6 +22,7 @@ import { KeyValuePair } from 'src/engine/core-modules/key-value-pair/key-value-p
import { PostgresCredentials } from 'src/engine/core-modules/postgres-credentials/postgres-credentials.entity';
import { WorkspaceSSOIdentityProvider } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
registerEnumType(WorkspaceActivationStatus, {
@ -121,6 +122,11 @@ export class Workspace {
)
workspaceSSOIdentityProviders: Relation<WorkspaceSSOIdentityProvider[]>;
@OneToMany(() => AgentEntity, (agent) => agent.workspace, {
onDelete: 'CASCADE',
})
agents: Relation<AgentEntity[]>;
@Field()
@Column({ default: 1 })
metadataVersion: number;

View File

@ -0,0 +1,65 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GqlExecutionContext } from '@nestjs/graphql';
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 { TypedReflect } from 'src/utils/typed-reflect';
export const FEATURE_FLAG_KEY = 'feature-flag-metadata-args';
export function RequireFeatureFlag(featureFlag: FeatureFlagKey) {
return (
target: object,
propertyKey?: string,
descriptor?: PropertyDescriptor,
) => {
TypedReflect.defineMetadata(
FEATURE_FLAG_KEY,
featureFlag,
descriptor?.value || target,
);
return descriptor;
};
}
@Injectable()
export class FeatureFlagGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly featureFlagService: FeatureFlagService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const request = ctx.getContext().req;
const workspaceId = request.workspace?.id;
if (!workspaceId) {
return false;
}
const featureFlag = this.reflector.get<FeatureFlagKey>(
FEATURE_FLAG_KEY,
context.getHandler(),
);
if (!featureFlag) {
return true;
}
const isEnabled = await this.featureFlagService.isFeatureEnabled(
featureFlag,
workspaceId,
);
if (!isEnabled) {
throw new Error(
`Feature flag "${featureFlag}" is not enabled for this workspace`,
);
}
return true;
}
}

View File

@ -0,0 +1,131 @@
import { Injectable } from '@nestjs/common';
import { createAnthropic } from '@ai-sdk/anthropic';
import { createOpenAI } from '@ai-sdk/openai';
import { generateObject } from 'ai';
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
import { getAIModelById } from 'src/engine/core-modules/ai/utils/get-ai-model-by-id';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { resolveInput } from 'src/modules/workflow/workflow-executor/utils/variable-resolver.util';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { convertOutputSchemaToZod } from './utils/convert-output-schema-to-zod';
export interface AgentExecutionResult {
object: object;
usage: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
}
@Injectable()
export class AgentExecutionService {
constructor(private readonly twentyConfigService: TwentyConfigService) {}
private getModel = (modelId: ModelId, provider: ModelProvider) => {
switch (provider) {
case ModelProvider.OPENAI: {
const OpenAIProvider = createOpenAI({
apiKey: this.twentyConfigService.get('OPENAI_API_KEY'),
});
return OpenAIProvider(modelId);
}
case ModelProvider.ANTHROPIC: {
const AnthropicProvider = createAnthropic({
apiKey: this.twentyConfigService.get('ANTHROPIC_API_KEY'),
});
return AnthropicProvider(modelId);
}
default:
throw new AgentException(
`Unsupported provider: ${provider}`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
};
private async validateApiKey(provider: ModelProvider): Promise<void> {
let apiKey: string | undefined;
switch (provider) {
case ModelProvider.OPENAI:
apiKey = this.twentyConfigService.get('OPENAI_API_KEY');
break;
case ModelProvider.ANTHROPIC:
apiKey = this.twentyConfigService.get('ANTHROPIC_API_KEY');
break;
default:
throw new AgentException(
`Unsupported provider: ${provider}`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
if (!apiKey) {
throw new AgentException(
`${provider.toUpperCase()} API key not configured`,
AgentExceptionCode.API_KEY_NOT_CONFIGURED,
);
}
}
async executeAgent({
agent,
context,
schema,
}: {
agent: AgentEntity;
context: Record<string, unknown>;
schema: OutputSchema;
}): Promise<AgentExecutionResult> {
try {
const aiModel = getAIModelById(agent.modelId);
if (!aiModel) {
throw new AgentException(
`AI model with id ${agent.modelId} not found`,
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const provider = aiModel.provider;
await this.validateApiKey(provider);
const output = await generateObject({
model: this.getModel(agent.modelId, provider),
prompt: resolveInput(agent.prompt, context) as string,
schema: convertOutputSchemaToZod(schema),
});
return {
object: output.object,
usage: {
promptTokens: output.usage?.promptTokens ?? 0,
completionTokens: output.usage?.completionTokens ?? 0,
totalTokens: output.usage?.totalTokens,
},
};
} catch (error) {
if (error instanceof AgentException) {
throw error;
}
throw new AgentException(
error instanceof Error ? error.message : 'Agent execution failed',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
}
}

View File

@ -0,0 +1,56 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
export class AgentEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
name: string;
@Column({ nullable: true })
description: string;
@Column({ nullable: false, type: 'text' })
prompt: string;
@Column({ nullable: false, type: 'varchar' })
modelId: ModelId;
@Column({ nullable: true, type: 'jsonb' })
responseFormat: object;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@ManyToOne(() => Workspace, (workspace) => workspace.agents, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<Workspace>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}

View File

@ -0,0 +1,20 @@
import { CustomException } from 'src/utils/custom-exception';
export class AgentException extends CustomException {
declare code: AgentExceptionCode;
constructor(message: string, code: AgentExceptionCode) {
super(message, code);
}
}
export enum AgentExceptionCode {
AGENT_NOT_FOUND = 'AGENT_NOT_FOUND',
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
AGENT_ALREADY_EXISTS = 'AGENT_ALREADY_EXISTS',
AGENT_EXECUTION_FAILED = 'AGENT_EXECUTION_FAILED',
AGENT_EXECUTION_LIMIT_REACHED = 'AGENT_EXECUTION_LIMIT_REACHED',
AGENT_INVALID_PROMPT = 'AGENT_INVALID_PROMPT',
AGENT_INVALID_MODEL = 'AGENT_INVALID_MODEL',
UNSUPPORTED_MODEL = 'UNSUPPORTED_MODEL',
API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED',
}

View File

@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { AgentExecutionService } from './agent-execution.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, FeatureFlag], 'core'),
AiModule,
ThrottlerModule,
AuditModule,
FeatureFlagModule,
],
providers: [AgentResolver, AgentService, AgentExecutionService],
exports: [
AgentService,
AgentExecutionService,
TypeOrmModule.forFeature([AgentEntity], 'core'),
],
})
export class AgentModule {}

View File

@ -0,0 +1,66 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentService } from './agent.service';
import { AgentIdInput } from './dtos/agent-id.input';
import { AgentDTO } from './dtos/agent.dto';
import { CreateAgentInput } from './dtos/create-agent.input';
import { UpdateAgentInput } from './dtos/update-agent.input';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@Resolver()
export class AgentResolver {
constructor(private readonly agentService: AgentService) {}
@Query(() => AgentDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findOneAgent(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.agentService.findOneAgent(id, workspaceId);
}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findManyAgents(@AuthWorkspace() { id: workspaceId }: Workspace) {
return this.agentService.findManyAgents(workspaceId);
}
@Mutation(() => AgentDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async createOneAgent(
@Args('input') input: CreateAgentInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.agentService.createOneAgent(input, workspaceId);
}
@Mutation(() => AgentDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async updateOneAgent(
@Args('input') input: UpdateAgentInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.agentService.updateOneAgent(input, workspaceId);
}
@Mutation(() => AgentDTO)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async deleteOneAgent(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
return this.agentService.deleteOneAgent(id, workspaceId);
}
}

View File

@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@Injectable()
export class AgentService {
constructor(
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
) {}
async findManyAgents(workspaceId: string) {
return this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
});
if (!agent) {
throw new AgentException(
`Agent with id ${id} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
return agent;
}
async createOneAgent(
input: {
name: string;
description?: string;
prompt: string;
modelId: ModelId;
responseFormat?: object;
},
workspaceId: string,
) {
const agent = this.agentRepository.create({
...input,
workspaceId,
});
const createdAgent = await this.agentRepository.save(agent);
return this.findOneAgent(createdAgent.id, workspaceId);
}
async updateOneAgent(
input: {
id: string;
name?: string;
description?: string;
prompt?: string;
modelId?: ModelId;
responseFormat?: object;
},
workspaceId: string,
) {
const agent = await this.findOneAgent(input.id, workspaceId);
const updatedAgent = await this.agentRepository.save({
...agent,
...input,
});
return updatedAgent;
}
async deleteOneAgent(id: string, workspaceId: string) {
const agent = await this.findOneAgent(id, workspaceId);
await this.agentRepository.softDelete({ id: agent.id });
return agent;
}
}

View File

@ -0,0 +1,9 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class AgentIdInput {
@Field(() => UUIDScalarType, { description: 'The id of the agent.' })
id!: string;
}

View File

@ -0,0 +1,45 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import { IsDateString, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
@ObjectType('Agent')
export class AgentDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@Field()
name: string;
@IsString()
@Field({ nullable: true })
description: string;
@IsString()
@IsNotEmpty()
@Field()
prompt: string;
@Field(() => String)
modelId: ModelId;
@Field(() => GraphQLJSON, { nullable: true })
responseFormat: object;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}

View File

@ -0,0 +1,34 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
@InputType()
export class CreateAgentInput {
@IsString()
@IsNotEmpty()
@Field()
name: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsNotEmpty()
@Field()
prompt: string;
@IsString()
@IsNotEmpty()
@Field(() => String)
modelId: ModelId;
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
responseFormat?: object;
}

View File

@ -0,0 +1,46 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
@InputType()
export class UpdateAgentInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field()
name?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
description?: string;
@IsString()
@IsOptional()
@Field()
prompt?: string;
@IsString()
@IsOptional()
@Field(() => String)
modelId?: ModelId;
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
responseFormat?: object;
}

View File

@ -0,0 +1,42 @@
import { z } from 'zod';
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
export const convertOutputSchemaToZod = (
schema: OutputSchema,
): z.ZodObject<Record<string, z.ZodTypeAny>> => {
const shape: Record<string, z.ZodTypeAny> = {};
for (const [fieldName, field] of Object.entries(schema)) {
if (field.isLeaf) {
let fieldSchema: z.ZodTypeAny;
switch (field.type) {
case 'TEXT':
fieldSchema = z.string();
break;
case 'NUMBER':
fieldSchema = z.number();
break;
case 'BOOLEAN':
fieldSchema = z.boolean();
break;
case 'DATE':
fieldSchema = z.string().describe('Date-time string');
break;
default:
throw new Error(
`Unsupported field type for AI agent output: ${field.type}`,
);
}
if (field.description) {
fieldSchema = fieldSchema.describe(field.description);
}
shape[fieldName] = fieldSchema;
}
}
return z.object(shape);
};

View File

@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
@ -16,6 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
FieldMetadataModule,
ObjectMetadataModule,
ServerlessFunctionModule,
AgentModule,
WorkspaceMetadataVersionModule,
WorkspaceMigrationModule,
RemoteServerModule,
@ -28,6 +30,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
FieldMetadataModule,
ObjectMetadataModule,
ServerlessFunctionModule,
AgentModule,
RemoteServerModule,
RoleModule,
PermissionsModule,

View File

@ -5,6 +5,7 @@ import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
@ -20,6 +21,7 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles
FileModule,
ThrottlerModule,
AuditModule,
FeatureFlagModule,
],
providers: [ServerlessFunctionService, ServerlessFunctionResolver],
exports: [ServerlessFunctionService],

View File

@ -5,9 +5,9 @@ import { InjectRepository } from '@nestjs/typeorm';
import graphqlTypeJson from 'graphql-type-json';
import { Repository } from 'typeorm';
import { FeatureFlag } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { FeatureFlagGuard } from 'src/engine/guards/feature-flag.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/execute-serverless-function.input';
@ -21,13 +21,11 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
@UseGuards(WorkspaceAuthGuard)
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@Resolver()
export class ServerlessFunctionResolver {
constructor(
private readonly serverlessFunctionService: ServerlessFunctionService,
@InjectRepository(FeatureFlag, 'core')
private readonly featureFlagRepository: Repository<FeatureFlag>,
@InjectRepository(ServerlessFunctionEntity, 'core')
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
) {}

View File

@ -5,6 +5,7 @@ export type Leaf = {
type?: InputSchemaPropertyType;
icon?: string;
label?: string;
description?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
};
@ -14,6 +15,7 @@ export type Node = {
type?: InputSchemaPropertyType;
icon?: string;
label?: string;
description?: string;
value: OutputSchema;
};

View File

@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module';
@ -11,6 +12,7 @@ import { WorkflowRunnerModule } from 'src/modules/workflow/workflow-runner/workf
@Module({
imports: [
AgentModule,
WorkflowSchemaModule,
ServerlessFunctionModule,
WorkflowRunnerModule,

View File

@ -8,6 +8,7 @@ import { v4 } from 'uuid';
import { BASE_TYPESCRIPT_PROJECT_INPUT_SCHEMA } from 'src/engine/core-modules/serverless/drivers/constants/base-typescript-project-input-schema';
import { CreateWorkflowVersionStepInput } from 'src/engine/core-modules/workflow/dtos/create-workflow-version-step-input.dto';
import { WorkflowActionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-step.dto';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@ -50,6 +51,7 @@ export class WorkflowVersionStepWorkspaceService {
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workflowSchemaWorkspaceService: WorkflowSchemaWorkspaceService,
private readonly serverlessFunctionService: ServerlessFunctionService,
private readonly agentService: AgentService,
@InjectRepository(ObjectMetadataEntity, 'core')
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
private readonly workflowRunWorkspaceService: WorkflowRunWorkspaceService,
@ -350,11 +352,13 @@ export class WorkflowVersionStepWorkspaceService {
}): Promise<WorkflowAction> {
// We don't enrich on the fly for code and HTTP request workflow actions.
// For code actions, OutputSchema is computed and updated when testing the serverless function.
// For HTTP requests, OutputSchema is determined by the expamle response input
// For HTTP requests and AI agent, OutputSchema is determined by the expamle response input
if (
[WorkflowActionType.CODE, WorkflowActionType.HTTP_REQUEST].includes(
step.type,
)
[
WorkflowActionType.CODE,
WorkflowActionType.HTTP_REQUEST,
WorkflowActionType.AI_AGENT,
].includes(step.type)
) {
return step;
}
@ -396,6 +400,17 @@ export class WorkflowVersionStepWorkspaceService {
}
break;
}
case WorkflowActionType.AI_AGENT: {
const agent = await this.agentService.findOneAgent(
step.settings.input.agentId,
workspaceId,
);
if (agent) {
await this.agentService.deleteOneAgent(agent.id, workspaceId);
}
break;
}
}
}
@ -578,6 +593,37 @@ export class WorkflowVersionStepWorkspaceService {
},
};
}
case WorkflowActionType.AI_AGENT: {
const newAgent = await this.agentService.createOneAgent(
{
name: 'AI Agent Workflow Step',
description: 'Created automatically for workflow step',
prompt: '',
modelId: 'gpt-4o',
},
workspaceId,
);
if (!isDefined(newAgent)) {
throw new WorkflowVersionStepException(
'Failed to create AI Agent Step',
WorkflowVersionStepExceptionCode.FAILURE,
);
}
return {
id: newStepId,
name: 'AI Agent',
type: WorkflowActionType.AI_AGENT,
valid: false,
settings: {
...BASE_STEP_DEFINITION,
input: {
agentId: newAgent.id,
},
},
};
}
default:
throw new WorkflowVersionStepException(
`WorkflowActionType '${type}' unknown`,

View File

@ -6,6 +6,7 @@ import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { AiAgentWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent.workflow-action';
import { CodeWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code.workflow-action';
import { FilterWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter.workflow-action';
import { FormWorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form.workflow-action';
@ -29,6 +30,7 @@ export class WorkflowExecutorFactory {
private readonly formWorkflowAction: FormWorkflowAction,
private readonly filterWorkflowAction: FilterWorkflowAction,
private readonly httpRequestWorkflowAction: HttpRequestWorkflowAction,
private readonly aiAgentWorkflowAction: AiAgentWorkflowAction,
) {}
get(stepType: WorkflowActionType): WorkflowExecutor {
@ -51,6 +53,8 @@ export class WorkflowExecutorFactory {
return this.filterWorkflowAction;
case WorkflowActionType.HTTP_REQUEST:
return this.httpRequestWorkflowAction;
case WorkflowActionType.AI_AGENT:
return this.aiAgentWorkflowAction;
default:
throw new WorkflowStepExecutorException(
`Workflow step executor not found for step type '${stepType}'`,

View File

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiDriver } from 'src/engine/core-modules/ai/interfaces/ai.interface';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { AiAgentWorkflowAction } from './ai-agent.workflow-action';
@Module({
imports: [
AgentModule,
AiModule.forRoot({
useFactory: () => ({ type: AiDriver.OPENAI }),
}),
TypeOrmModule.forFeature([AgentEntity], 'core'),
],
providers: [ScopedWorkspaceContextFactory, AiAgentWorkflowAction],
exports: [AiAgentWorkflowAction],
})
export class AiAgentActionModule {}

View File

@ -0,0 +1,98 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkflowExecutor } from 'src/modules/workflow/workflow-executor/interfaces/workflow-executor.interface';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
} from 'src/modules/workflow/workflow-executor/exceptions/workflow-step-executor.exception';
import { WorkflowExecutorInput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-input';
import { WorkflowExecutorOutput } from 'src/modules/workflow/workflow-executor/types/workflow-executor-output.type';
import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard';
@Injectable()
export class AiAgentWorkflowAction implements WorkflowExecutor {
constructor(
private readonly agentExecutionService: AgentExecutionService,
private readonly aiBillingService: AIBillingService,
@InjectRepository(AgentEntity, 'core')
private readonly agentRepository: Repository<AgentEntity>,
) {}
async execute({
currentStepId,
steps,
context,
}: WorkflowExecutorInput): Promise<WorkflowExecutorOutput> {
const step = steps.find((step) => step.id === currentStepId);
if (!step) {
throw new WorkflowStepExecutorException(
'Step not found',
WorkflowStepExecutorExceptionCode.STEP_NOT_FOUND,
);
}
if (!isWorkflowAiAgentAction(step)) {
throw new WorkflowStepExecutorException(
'Step is not an AI Agent action',
WorkflowStepExecutorExceptionCode.INVALID_STEP_TYPE,
);
}
const { agentId } = step.settings.input;
const workspaceId = context.workspaceId as string;
try {
const agent = await this.agentRepository.findOne({
where: {
id: agentId,
workspaceId,
},
});
if (!agent) {
throw new AgentException(
`Agent with id ${agentId} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const executionResult = await this.agentExecutionService.executeAgent({
agent,
context,
schema: step.settings.outputSchema,
});
await this.aiBillingService.calculateAndBillUsage(
agent.modelId,
executionResult.usage,
workspaceId,
);
return { result: executionResult.object };
} catch (error) {
if (error instanceof AgentException) {
return {
error: `${error.message} (${error.code})`,
};
}
return {
error:
error instanceof Error ? error.message : 'AI Agent execution failed',
};
}
}
}

View File

@ -0,0 +1,11 @@
import {
WorkflowAction,
WorkflowActionType,
WorkflowAiAgentAction,
} from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type';
export const isWorkflowAiAgentAction = (
action: WorkflowAction,
): action is WorkflowAiAgentAction => {
return action.type === WorkflowActionType.AI_AGENT;
};

View File

@ -0,0 +1,3 @@
export type WorkflowAiAgentActionInput = {
agentId: string;
};

View File

@ -0,0 +1,6 @@
import { WorkflowAiAgentActionInput } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-input.type';
import { BaseWorkflowActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action-settings.type';
export type WorkflowAiAgentActionSettings = BaseWorkflowActionSettings & {
input: WorkflowAiAgentActionInput;
};

View File

@ -1,4 +1,5 @@
import { OutputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/output-schema.type';
import { WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
@ -32,4 +33,5 @@ export type WorkflowActionSettings =
| WorkflowFindRecordsActionSettings
| WorkflowFormActionSettings
| WorkflowFilterActionSettings
| WorkflowHttpRequestActionSettings;
| WorkflowHttpRequestActionSettings
| WorkflowAiAgentActionSettings;

View File

@ -1,3 +1,4 @@
import { WorkflowAiAgentActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/types/workflow-ai-agent-action-settings.type';
import { WorkflowCodeActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/code/types/workflow-code-action-settings.type';
import { WorkflowFilterActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/types/workflow-filter-action-settings.type';
import { WorkflowFormActionSettings } from 'src/modules/workflow/workflow-executor/workflow-actions/form/types/workflow-form-action-settings.type';
@ -21,6 +22,7 @@ export enum WorkflowActionType {
FORM = 'FORM',
FILTER = 'FILTER',
HTTP_REQUEST = 'HTTP_REQUEST',
AI_AGENT = 'AI_AGENT',
}
type BaseWorkflowAction = {
@ -77,6 +79,11 @@ export type WorkflowHttpRequestAction = BaseWorkflowAction & {
settings: WorkflowHttpRequestActionSettings;
};
export type WorkflowAiAgentAction = BaseWorkflowAction & {
type: WorkflowActionType.AI_AGENT;
settings: WorkflowAiAgentActionSettings;
};
export type WorkflowAction =
| WorkflowCodeAction
| WorkflowSendEmailAction
@ -86,4 +93,5 @@ export type WorkflowAction =
| WorkflowFindRecordsAction
| WorkflowFormAction
| WorkflowFilterAction
| WorkflowHttpRequestAction;
| WorkflowHttpRequestAction
| WorkflowAiAgentAction;

View File

@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module';
import { WorkflowExecutorFactory } from 'src/modules/workflow/workflow-executor/factories/workflow-executor.factory';
import { AiAgentActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/ai-agent-action.module';
import { CodeActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/code/code-action.module';
import { FilterActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/filter/filter-action.module';
import { FormActionModule } from 'src/modules/workflow/workflow-executor/workflow-actions/form/form-action.module';
@ -24,6 +26,8 @@ import { WorkflowRunModule } from 'src/modules/workflow/workflow-runner/workflow
BillingModule,
FilterActionModule,
HttpRequestActionModule,
AiAgentActionModule,
FeatureFlagModule,
],
providers: [
WorkflowExecutorWorkspaceService,

View File

@ -3,6 +3,7 @@ import 'reflect-metadata';
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
import { WorkspaceEntityDuplicateCriteria } from 'src/engine/api/graphql/workspace-query-builder/types/workspace-entity-duplicate-criteria.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { ConfigVariablesMetadataMap } from 'src/engine/core-modules/twenty-config/decorators/config-variables-metadata.decorator';
export interface ReflectMetadataTypeMap {
@ -16,6 +17,7 @@ export interface ReflectMetadataTypeMap {
['workspace:duplicate-criteria-metadata-args']: WorkspaceEntityDuplicateCriteria[];
['config-variables']: ConfigVariablesMetadataMap;
['workspace:is-searchable-metadata-args']: boolean;
['feature-flag-metadata-args']: FeatureFlagKey;
}
export class TypedReflect {