From da79afa80a2fd8cb5f3ee9e34705677b8d421f9b Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Wed, 30 Jul 2025 20:26:58 +0530 Subject: [PATCH] AI Agent handoffs (#13472) --- .../1753709914396-AddAgentHandoffTable.ts | 45 ++++++ .../workspace/workspace.entity.ts | 6 + .../agent/agent-execution.service.ts | 52 ++----- .../agent/agent-handoff-executor.service.ts | 117 +++++++++++++++ .../agent/agent-handoff.entity.ts | 71 +++++++++ .../agent/agent-handoff.service.ts | 140 ++++++++++++++++++ .../agent/agent-tool.service.ts | 107 +++++++++---- .../metadata-modules/agent/agent.entity.ts | 7 + .../metadata-modules/agent/agent.exception.ts | 1 + .../metadata-modules/agent/agent.module.ts | 8 + .../agent/utils/agent-tool-test-utils.ts | 17 +++ 11 files changed, 506 insertions(+), 65 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1753709914396-AddAgentHandoffTable.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.service.ts diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1753709914396-AddAgentHandoffTable.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1753709914396-AddAgentHandoffTable.ts new file mode 100644 index 0000000000..8d5c676e5b --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1753709914396-AddAgentHandoffTable.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAgentHandoffTable1753709914396 implements MigrationInterface { + name = 'AddAgentHandoffTable1753709914396'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."agentHandoff" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "fromAgentId" uuid NOT NULL, "toAgentId" uuid NOT NULL, "workspaceId" uuid NOT NULL, "description" text, "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_44aad35fb18ea2696f242d3fd76" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_AGENT_HANDOFF_FROM_TO_WORKSPACE_UNIQUE" ON "core"."agentHandoff" ("fromAgentId", "toAgentId", "workspaceId") WHERE "deletedAt" IS NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_AGENT_HANDOFF_ID_DELETED_AT" ON "core"."agentHandoff" ("id", "deletedAt") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" ADD CONSTRAINT "FK_7128daa5ac9f787388391b52269" FOREIGN KEY ("fromAgentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" ADD CONSTRAINT "FK_020114432e51baf65bbbc46e350" FOREIGN KEY ("toAgentId") REFERENCES "core"."agent"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" ADD CONSTRAINT "FK_7ea2d1182dc5324e7cef9903302" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" DROP CONSTRAINT "FK_7ea2d1182dc5324e7cef9903302"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" DROP CONSTRAINT "FK_020114432e51baf65bbbc46e350"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."agentHandoff" DROP CONSTRAINT "FK_7128daa5ac9f787388391b52269"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_AGENT_HANDOFF_ID_DELETED_AT"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_AGENT_HANDOFF_FROM_TO_WORKSPACE_UNIQUE"`, + ); + await queryRunner.query(`DROP TABLE "core"."agentHandoff"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index fd9b48e102..8cb0d39641 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -24,6 +24,7 @@ import { PostgresCredentials } from 'src/engine/core-modules/postgres-credential 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 { Webhook } from 'src/engine/core-modules/webhook/webhook.entity'; +import { AgentHandoffEntity } from 'src/engine/metadata-modules/agent/agent-handoff.entity'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto'; import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; @@ -130,6 +131,11 @@ export class Workspace { }) agents: Relation; + @OneToMany(() => AgentHandoffEntity, (handoff) => handoff.workspace, { + onDelete: 'CASCADE', + }) + agentHandoffs: Relation; + @OneToMany(() => Webhook, (webhook) => webhook.workspace) webhooks: Relation; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts index 6ebfeb1ff4..925887267b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-execution.service.ts @@ -3,8 +3,6 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Readable } from 'stream'; -import { createAnthropic } from '@ai-sdk/anthropic'; -import { createOpenAI } from '@ai-sdk/openai'; import { CoreMessage, CoreUserMessage, @@ -17,10 +15,7 @@ import { } from 'ai'; import { In, Repository } from 'typeorm'; -import { - ModelId, - ModelProvider, -} from 'src/engine/core-modules/ai/constants/ai-models.const'; +import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const'; import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { FileService } from 'src/engine/core-modules/file/services/file.service'; @@ -73,38 +68,6 @@ export class AgentExecutionService { private readonly fileRepository: Repository, ) {} - getModel = (modelId: ModelId, provider: ModelProvider) => { - switch (provider) { - case ModelProvider.OPENAI_COMPATIBLE: { - const OpenAIProvider = createOpenAI({ - baseURL: this.twentyConfigService.get('OPENAI_COMPATIBLE_BASE_URL'), - apiKey: this.twentyConfigService.get('OPENAI_COMPATIBLE_API_KEY'), - }); - - return OpenAIProvider(modelId); - } - 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 { let apiKey: string | undefined; @@ -171,10 +134,21 @@ export class AgentExecutionService { this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`); + const registeredModel = this.aiModelRegistryService.getModel( + aiModel.modelId, + ); + + if (!registeredModel) { + throw new AgentException( + `Model ${aiModel.modelId} not found in registry`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + return { system, tools, - model: this.getModel(aiModel.modelId, aiModel.provider), + model: registeredModel.model, ...(messages && { messages }), ...(prompt && { prompt }), maxSteps: AGENT_CONFIG.MAX_STEPS, diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts new file mode 100644 index 0000000000..125e2603e0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff-executor.service.ts @@ -0,0 +1,117 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { generateText } from 'ai'; +import { Repository } from 'typeorm'; + +import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service'; + +import { AgentHandoffService } from './agent-handoff.service'; +import { AgentEntity } from './agent.entity'; +import { AgentException, AgentExceptionCode } from './agent.exception'; + +export type HandoffRequest = { + fromAgentId: string; + toAgentId: string; + workspaceId: string; + reason: string; + context?: string; +}; + +@Injectable() +export class AgentHandoffExecutorService { + private readonly logger = new Logger(AgentHandoffExecutorService.name); + + constructor( + @InjectRepository(AgentEntity, 'core') + private readonly agentRepository: Repository, + private readonly agentHandoffService: AgentHandoffService, + private readonly aiModelRegistryService: AiModelRegistryService, + ) {} + + async executeHandoff(handoffRequest: HandoffRequest) { + try { + const { fromAgentId, toAgentId, workspaceId, reason } = handoffRequest; + + const canHandoff = await this.agentHandoffService.canHandoffTo({ + fromAgentId, + toAgentId, + workspaceId, + }); + + if (!canHandoff) { + throw new AgentException( + `Agent ${fromAgentId} is not allowed to hand off to agent ${toAgentId}`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + + const targetAgent = await this.agentRepository.findOne({ + where: { id: toAgentId, workspaceId }, + }); + + if (!targetAgent) { + throw new AgentException( + `Target agent ${toAgentId} not found`, + AgentExceptionCode.AGENT_NOT_FOUND, + ); + } + + const registeredModel = this.aiModelRegistryService.getModel( + targetAgent.modelId, + ); + + if (!registeredModel) { + throw new AgentException( + `Model ${targetAgent.modelId} not found in registry`, + AgentExceptionCode.AGENT_EXECUTION_FAILED, + ); + } + + const aiRequestConfig = { + system: targetAgent.prompt, + prompt: this.createHandoffPrompt(handoffRequest), + model: registeredModel.model, + }; + + const textResponse = await generateText(aiRequestConfig); + + return { + success: true, + newAgentId: toAgentId, + newAgentName: targetAgent.name, + message: `Successfully transferred to ${targetAgent.name}. ${reason}`, + response: textResponse.text, + }; + } catch (error) { + this.logger.error( + `Handoff execution failed: ${error.message}`, + error.stack, + ); + + return { + success: false, + newAgentId: handoffRequest.toAgentId, + newAgentName: 'Unknown', + error: error.message, + }; + } + } + + private createHandoffPrompt(handoffRequest: HandoffRequest): string { + const { reason, context } = handoffRequest; + + const prompt = ` +You have received a handoff from another AI agent. This means the previous agent has determined that you are better suited to handle this conversation based on your specialized knowledge and capabilities. + +The previous agent has transferred this conversation to you because: ${reason} + +Additional context from the previous agent: +${context || 'No additional context provided'} + +Please continue the conversation naturally, acknowledging that you are taking over from the previous agent. Use your specialized knowledge to provide the best possible assistance to the user. +`; + + return prompt; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.entity.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.entity.ts new file mode 100644 index 0000000000..57b701394b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.entity.ts @@ -0,0 +1,71 @@ +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 { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; + +import { AgentEntity } from './agent.entity'; + +@Entity('agentHandoff') +@Index('IDX_AGENT_HANDOFF_ID_DELETED_AT', ['id', 'deletedAt']) +@Index( + 'IDX_AGENT_HANDOFF_FROM_TO_WORKSPACE_UNIQUE', + ['fromAgentId', 'toAgentId', 'workspaceId'], + { + unique: true, + where: '"deletedAt" IS NULL', + }, +) +export class AgentHandoffEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false, type: 'uuid' }) + fromAgentId: string; + + @Column({ nullable: false, type: 'uuid' }) + toAgentId: string; + + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @Column({ nullable: true, type: 'text' }) + description: string; + + @ManyToOne(() => AgentEntity, (agent) => agent.outgoingHandoffs, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'fromAgentId' }) + fromAgent: Relation; + + @ManyToOne(() => AgentEntity, (agent) => agent.incomingHandoffs, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'toAgentId' }) + toAgent: Relation; + + @ManyToOne(() => Workspace, (workspace) => workspace.agentHandoffs, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; + + @DeleteDateColumn({ type: 'timestamptz' }) + deletedAt?: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.service.ts new file mode 100644 index 0000000000..3bdf44e9f7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-handoff.service.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { AgentHandoffEntity } from './agent-handoff.entity'; +import { AgentEntity } from './agent.entity'; +import { AgentException, AgentExceptionCode } from './agent.exception'; + +@Injectable() +export class AgentHandoffService { + constructor( + @InjectRepository(AgentEntity, 'core') + private readonly agentRepository: Repository, + @InjectRepository(AgentHandoffEntity, 'core') + private readonly agentHandoffRepository: Repository, + ) {} + + async canHandoffTo({ + fromAgentId, + toAgentId, + workspaceId, + }: { + fromAgentId: string; + toAgentId: string; + workspaceId: string; + }): Promise { + const handoff = await this.agentHandoffRepository.findOne({ + where: { + fromAgentId, + toAgentId, + workspaceId, + }, + }); + + return Boolean(handoff); + } + + async getHandoffTargets({ + fromAgentId, + workspaceId, + }: { + fromAgentId: string; + workspaceId: string; + }): Promise { + const handoffs = await this.agentHandoffRepository.find({ + where: { + fromAgentId, + workspaceId, + }, + relations: ['toAgent'], + }); + + return handoffs.map((handoff) => handoff.toAgent); + } + + async createHandoff({ + fromAgentId, + toAgentId, + workspaceId, + description, + }: { + fromAgentId: string; + toAgentId: string; + workspaceId: string; + description?: string; + }): Promise { + const [fromAgent, toAgent] = await Promise.all([ + this.agentRepository.findOne({ + where: { id: fromAgentId, workspaceId }, + }), + this.agentRepository.findOne({ + where: { id: toAgentId, workspaceId }, + }), + ]); + + if (!fromAgent) { + throw new AgentException( + `Agent with id ${fromAgentId} not found in workspace`, + AgentExceptionCode.AGENT_NOT_FOUND, + ); + } + + if (!toAgent) { + throw new AgentException( + `Agent with id ${toAgentId} not found in workspace`, + AgentExceptionCode.AGENT_NOT_FOUND, + ); + } + + const existingHandoff = await this.agentHandoffRepository.findOne({ + where: { + fromAgentId, + toAgentId, + workspaceId, + }, + }); + + if (existingHandoff) { + throw new AgentException( + `Handoff from ${fromAgent.name} to ${toAgent.name} already exists`, + AgentExceptionCode.HANDOFF_ALREADY_EXISTS, + ); + } + + const handoff = await this.agentHandoffRepository.save({ + fromAgentId, + toAgentId, + workspaceId, + description, + }); + + return handoff; + } + + async removeHandoff({ + fromAgentId, + toAgentId, + workspaceId, + }: { + fromAgentId: string; + toAgentId: string; + workspaceId: string; + }): Promise { + await this.agentHandoffRepository.delete({ + fromAgentId, + toAgentId, + workspaceId, + }); + } + + async getWorkspaceHandoffs( + workspaceId: string, + ): Promise { + return this.agentHandoffRepository.find({ + where: { workspaceId }, + relations: ['fromAgent', 'toAgent'], + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts index d98675553c..6e59972fcf 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent-tool.service.ts @@ -3,16 +3,22 @@ import { InjectRepository } from '@nestjs/typeorm'; import { ToolSet } from 'ai'; import { Repository } from 'typeorm'; +import { z } from 'zod'; import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service'; import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; +import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; +import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { camelCase } from 'src/utils/camel-case'; @Injectable() export class AgentToolService { constructor( private readonly agentService: AgentService, + private readonly agentHandoffService: AgentHandoffService, + private readonly agentHandoffExecutorService: AgentHandoffExecutorService, @InjectRepository(RoleEntity, 'core') private readonly roleRepository: Repository, private readonly toolService: ToolService, @@ -23,39 +29,88 @@ export class AgentToolService { agentId: string, workspaceId: string, ): Promise { - try { - const agent = await this.agentService.findOneAgent(agentId, workspaceId); + const agent = await this.agentService.findOneAgent(agentId, workspaceId); - if (!agent.roleId) { - const actionTools = await this.toolAdapterService.getTools(); + const handoffTools = await this.generateHandoffTools(agentId, workspaceId); - return actionTools; - } + if (!agent.roleId) { + const actionTools = await this.toolAdapterService.getTools(); - const role = await this.roleRepository.findOne({ - where: { - id: agent.roleId, - workspaceId, - }, - }); + return { ...actionTools, ...handoffTools }; + } - if (!role) { - return {}; - } - - const actionTools = await this.toolAdapterService.getTools( - role.id, + const role = await this.roleRepository.findOne({ + where: { + id: agent.roleId, workspaceId, - ); + }, + }); - const databaseTools = await this.toolService.listTools( - role.id, - workspaceId, - ); - - return { ...databaseTools, ...actionTools }; - } catch (error) { + if (!role) { return {}; } + + const actionTools = await this.toolAdapterService.getTools( + role.id, + workspaceId, + ); + + const databaseTools = await this.toolService.listTools( + role.id, + workspaceId, + ); + + return { ...databaseTools, ...actionTools, ...handoffTools }; + } + + private async generateHandoffTools( + agentId: string, + workspaceId: string, + ): Promise { + const handoffTargets = await this.agentHandoffService.getHandoffTargets({ + fromAgentId: agentId, + workspaceId, + }); + + const handoffTools = handoffTargets.reduce( + (tools, targetAgent) => { + const toolName = `transfer_to_${camelCase(targetAgent.name)}`; + + const handoffSchema = z.object({ + reason: z.string().describe('Reason for transferring to this agent'), + context: z + .string() + .optional() + .describe('Additional context to pass to the receiving agent'), + }); + + tools[toolName] = { + description: `Transfer this request to ${targetAgent.name} when you need their specialized expertise. Use this when the user's request is outside your capabilities or when ${targetAgent.name} would be better suited to handle the request.`, + parameters: handoffSchema, + execute: async ({ reason, context }) => { + const result = + await this.agentHandoffExecutorService.executeHandoff({ + fromAgentId: agentId, + toAgentId: targetAgent.id, + workspaceId, + reason, + context, + }); + + return { + success: result.success, + message: result.message || `Transferred to ${targetAgent.name}`, + newAgentId: result.newAgentId, + newAgentName: result.newAgentName, + }; + }, + }; + + return tools; + }, + {}, + ); + + return handoffTools; } } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts index 1b9405f895..6ed86e6a79 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.entity.ts @@ -18,6 +18,7 @@ import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { AgentChatThreadEntity } from './agent-chat-thread.entity'; +import { AgentHandoffEntity } from './agent-handoff.entity'; @Entity('agent') @Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt']) @@ -62,6 +63,12 @@ export class AgentEntity { @OneToMany(() => AgentChatThreadEntity, (chatThread) => chatThread.agent) chatThreads: Relation; + @OneToMany(() => AgentHandoffEntity, (handoff) => handoff.fromAgent) + outgoingHandoffs: Relation; + + @OneToMany(() => AgentHandoffEntity, (handoff) => handoff.toAgent) + incomingHandoffs: Relation; + @CreateDateColumn({ type: 'timestamptz' }) createdAt: Date; diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts index 4ea6adc3cf..f1e2def599 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.exception.ts @@ -13,4 +13,5 @@ export enum AgentExceptionCode { API_KEY_NOT_CONFIGURED = 'API_KEY_NOT_CONFIGURED', USER_WORKSPACE_ID_NOT_FOUND = 'USER_WORKSPACE_ID_NOT_FOUND', ROLE_NOT_FOUND = 'ROLE_NOT_FOUND', + HANDOFF_ALREADY_EXISTS = 'HANDOFF_ALREADY_EXISTS', } diff --git a/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts index 713a55b25d..a78cd42feb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/agent/agent.module.ts @@ -23,6 +23,9 @@ import { AgentChatThreadEntity } from './agent-chat-thread.entity'; import { AgentChatResolver } from './agent-chat.resolver'; import { AgentChatService } from './agent-chat.service'; import { AgentExecutionService } from './agent-execution.service'; +import { AgentHandoffExecutorService } from './agent-handoff-executor.service'; +import { AgentHandoffEntity } from './agent-handoff.entity'; +import { AgentHandoffService } from './agent-handoff.service'; import { AgentStreamingService } from './agent-streaming.service'; import { AgentTitleGenerationService } from './agent-title-generation.service'; import { AgentToolService } from './agent-tool.service'; @@ -35,6 +38,7 @@ import { AgentService } from './agent.service'; TypeOrmModule.forFeature( [ AgentEntity, + AgentHandoffEntity, RoleEntity, RoleTargetsEntity, AgentChatMessageEntity, @@ -66,6 +70,8 @@ import { AgentService } from './agent.service'; AgentChatService, AgentStreamingService, AgentTitleGenerationService, + AgentHandoffExecutorService, + AgentHandoffService, ], exports: [ AgentService, @@ -78,6 +84,8 @@ import { AgentService } from './agent.service'; [AgentEntity, AgentChatMessageEntity, AgentChatThreadEntity], 'core', ), + AgentHandoffExecutorService, + AgentHandoffService, ], }) export class AgentModule {} diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index 7da2f66f9d..9ec00457d0 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -7,6 +7,8 @@ import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-ada import { ToolService } from 'src/engine/core-modules/ai/services/tool.service'; import { ToolRegistryService } from 'src/engine/core-modules/tool/services/tool-registry.service'; import { SendEmailTool } from 'src/engine/core-modules/tool/tools/send-email-tool/send-email-tool'; +import { AgentHandoffExecutorService } from 'src/engine/metadata-modules/agent/agent-handoff-executor.service'; +import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service'; import { AgentToolService } from 'src/engine/metadata-modules/agent/agent-tool.service'; import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity'; import { AgentService } from 'src/engine/metadata-modules/agent/agent.service'; @@ -112,6 +114,19 @@ export const createAgentToolTestModule = hasToolPermission: jest.fn(), }, }, + { + provide: AgentHandoffService, + useValue: { + getHandoffTargets: jest.fn().mockResolvedValue([]), + canHandoffTo: jest.fn().mockResolvedValue(true), + }, + }, + { + provide: AgentHandoffExecutorService, + useValue: { + executeHandoff: jest.fn().mockResolvedValue({ success: true }), + }, + }, ], }).compile(); @@ -147,6 +162,8 @@ export const createAgentToolTestModule = createdAt: new Date(), updatedAt: new Date(), chatThreads: [], + incomingHandoffs: [], + outgoingHandoffs: [], }; const testRole: RoleEntity = {