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,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>,
) {}