AI Agent handoffs (#13472)
This commit is contained in:
+45
@@ -0,0 +1,45 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddAgentHandoffTable1753709914396 implements MigrationInterface {
|
||||
name = 'AddAgentHandoffTable1753709914396';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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"`);
|
||||
}
|
||||
}
|
||||
@@ -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<AgentEntity[]>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.workspace, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
agentHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@OneToMany(() => Webhook, (webhook) => webhook.workspace)
|
||||
webhooks: Relation<Webhook[]>;
|
||||
|
||||
|
||||
+13
-39
@@ -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<FileEntity>,
|
||||
) {}
|
||||
|
||||
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<void> {
|
||||
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,
|
||||
|
||||
+117
@@ -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<AgentEntity>,
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<AgentEntity>;
|
||||
|
||||
@ManyToOne(() => AgentEntity, (agent) => agent.incomingHandoffs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'toAgentId' })
|
||||
toAgent: Relation<AgentEntity>;
|
||||
|
||||
@ManyToOne(() => Workspace, (workspace) => workspace.agentHandoffs, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'workspaceId' })
|
||||
workspace: Relation<Workspace>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
|
||||
@DeleteDateColumn({ type: 'timestamptz' })
|
||||
deletedAt?: Date;
|
||||
}
|
||||
@@ -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<AgentEntity>,
|
||||
@InjectRepository(AgentHandoffEntity, 'core')
|
||||
private readonly agentHandoffRepository: Repository<AgentHandoffEntity>,
|
||||
) {}
|
||||
|
||||
async canHandoffTo({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
toAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<boolean> {
|
||||
const handoff = await this.agentHandoffRepository.findOne({
|
||||
where: {
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
return Boolean(handoff);
|
||||
}
|
||||
|
||||
async getHandoffTargets({
|
||||
fromAgentId,
|
||||
workspaceId,
|
||||
}: {
|
||||
fromAgentId: string;
|
||||
workspaceId: string;
|
||||
}): Promise<AgentEntity[]> {
|
||||
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<AgentHandoffEntity> {
|
||||
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<void> {
|
||||
await this.agentHandoffRepository.delete({
|
||||
fromAgentId,
|
||||
toAgentId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
async getWorkspaceHandoffs(
|
||||
workspaceId: string,
|
||||
): Promise<AgentHandoffEntity[]> {
|
||||
return this.agentHandoffRepository.find({
|
||||
where: { workspaceId },
|
||||
relations: ['fromAgent', 'toAgent'],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<RoleEntity>,
|
||||
private readonly toolService: ToolService,
|
||||
@@ -23,39 +29,88 @@ export class AgentToolService {
|
||||
agentId: string,
|
||||
workspaceId: string,
|
||||
): Promise<ToolSet> {
|
||||
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<ToolSet> {
|
||||
const handoffTargets = await this.agentHandoffService.getHandoffTargets({
|
||||
fromAgentId: agentId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
const handoffTools = handoffTargets.reduce<ToolSet>(
|
||||
(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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AgentChatThreadEntity[]>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.fromAgent)
|
||||
outgoingHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.toAgent)
|
||||
incomingHandoffs: Relation<AgentHandoffEntity[]>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
+17
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user