Files
twenty/packages/twenty-server/src/engine/metadata-modules/ai-agent/agent.service.ts
T
Félix Malfait e7ebf51e50 Replace agent handoff system with planning-based router (#16003)
## Overview

This PR replaces the dynamic agent handoff system with a more
predictable planning-based router that decides upfront how to handle
multi-agent coordination.

## Major Changes

### 🔄 Architecture Shift: Handoffs → Planning

**Removed:**
- `AgentHandoffEntity` and handoff tracking system
- `AgentHandoffService` and `AgentHandoffExecutorService`
- Dynamic agent-to-agent transfers during execution
- Handoff tool generation and description templates

**Added:**
- `AiRouterService` with two strategies: `simple` (single agent) and
`planned` (multi-agent)
- `AgentPlanExecutorService` for executing multi-step plans
- Plan validation (cycle detection, dependency resolution)
- `UnifiedRouterResult` type with discriminated union

### 🤖 New Standard Agents

Added two new specialized agents:
- **Researcher Agent**: Web search, fact-finding, competitive
intelligence
- **Code Agent**: TypeScript function generation for serverless
workflows

### 🏗️ Router Refactoring (Latest)

Split router responsibilities into focused services:
- `AiRouterStrategyDeciderService`: Decides simple vs planned strategy
- `AiRouterPlanGeneratorService`: Generates and validates execution
plans
- `AiRouterService`: Coordinates between services (reduced from 426→275
lines)

### ⚙️ Configuration Improvements

- Added `outputStrategy` to agent definitions (`direct` vs `synthesize`)
- Removed hardcoded special cases for workflow-builder
- Added `plannerModel` field to workspace entity
- Increased `MAX_STEPS` from 10 to 25 for complex workflows

### 📝 Agent Prompt Refinements

Significantly simplified prompts for better clarity:
- Workflow Builder: 51→36 lines
- Helper: 49→28 lines
- Data Manipulator: Enhanced with sorting guidance

### 🔍 Enhanced Debugging

- Plan reasoning and step count in data message parts
- Router debug info with token usage tracking
- Better logging throughout execution pipeline

## Benefits

1. **Simpler Mental Model**: Router decides upfront vs dynamic transfers
2. **Better Predictability**: Users see the plan before execution
3. **Cleaner Architecture**: SRP with focused services
4. **Configuration Over Code**: Agent behavior via config, not hardcoded
logic
5. **Plan Validation**: Catches invalid dependencies and cycles

## Migration Notes

- Database migration removes `agentHandoff` table
- Adds `plannerModel` column to workspace table
- No API breaking changes (agent endpoints unchanged)

## Testing

- Integration tests updated to remove handoff dependencies
- Agent tool test utilities simplified
- Plan validation covered by new logic

## Next Steps (Future PRs)

- Parallel execution of independent plan steps
- Dynamic re-planning based on results
- Plan caching for common routing patterns
- Error recovery strategies in plan executor
2025-11-25 12:10:14 +01:00

258 lines
6.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { In, Repository } from 'typeorm';
import { AiAgentRoleService } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/ai-agent/dtos/update-agent.input';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { computeMetadataNameFromLabel } from 'src/engine/metadata-modules/utils/compute-metadata-name-from-label.util';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { AgentEntity } from './entities/agent.entity';
@Injectable()
export class AgentService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentRoleService: AiAgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
const agentRoleMap = await this.buildAgentRoleMap(workspaceId, agents);
return agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
private async buildAgentRoleMap(
workspaceId: string,
agents: AgentEntity[],
): Promise<Map<string, string>> {
const roleTargets = await this.roleTargetsRepository.find({
where: {
workspaceId,
agentId: In(agents.map((agent) => agent.id)),
},
});
const agentRoleMap = new Map<string, string>();
roleTargets.forEach((roleTarget) => {
if (roleTarget.agentId) {
agentRoleMap.set(roleTarget.agentId, roleTarget.roleId);
}
});
return agentRoleMap;
}
async findOneByApplicationAndStandardId({
applicationId,
standardId,
workspaceId,
}: {
applicationId: string;
standardId: string;
workspaceId: string;
}) {
return await this.agentRepository.findOne({
where: { applicationId, standardId, workspaceId },
});
}
async findOneAgent(
workspaceId: string,
{ id, name }: { id?: string; name?: string },
) {
this.validateAgentIdentifier(id, name);
const agent = await this.fetchAgent(workspaceId, id, name);
const roleId = await this.fetchAgentRoleId(workspaceId, agent.id);
return {
...agent,
roleId,
};
}
private validateAgentIdentifier(
id: string | undefined,
name: string | undefined,
): void {
if (!isNonEmptyString(id) && !isNonEmptyString(name)) {
throw new AgentException(
'Either id or name must be provided',
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
if (isNonEmptyString(id) && isNonEmptyString(name)) {
throw new AgentException(
'Cannot specify both id and name',
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
}
private async fetchAgent(
workspaceId: string,
id: string | undefined,
name: string | undefined,
): Promise<AgentEntity> {
const agent = await this.agentRepository.findOne({
where: id ? { id, workspaceId } : { name, workspaceId },
});
if (!agent) {
const identifier = id ? `id "${id}"` : `name "${name}"`;
throw new AgentException(
`Agent with ${identifier} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
return agent;
}
private async fetchAgentRoleId(
workspaceId: string,
agentId: string,
): Promise<string | null> {
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId,
workspaceId,
},
select: ['roleId'],
});
return roleTarget?.roleId || null;
}
async createOneAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
) {
const agent = this.buildNewAgent(input, workspaceId);
const createdAgent = await this.agentRepository.save(agent);
if (isNonEmptyString(input.roleId)) {
await this.assignRoleToNewAgent(
workspaceId,
createdAgent.id,
input.roleId,
);
}
return this.findOneAgent(workspaceId, { id: createdAgent.id });
}
private buildNewAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
): AgentEntity {
return this.agentRepository.create({
...input,
name: isNonEmptyString(input.name)
? input.name
: computeMetadataNameFromLabel(input.label),
workspaceId,
isCustom: input.isCustom,
});
}
private async assignRoleToNewAgent(
workspaceId: string,
agentId: string,
roleId: string,
): Promise<void> {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId,
roleId,
});
}
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
const agent = await this.findOneAgent(workspaceId, { id: input.id });
const updateData = this.buildUpdateData(agent, input);
const updatedAgent = await this.agentRepository.save(updateData);
if (!('roleId' in input)) {
return updatedAgent;
}
await this.updateAgentRole(workspaceId, agent.id, input.roleId);
return this.findOneAgent(workspaceId, { id: updatedAgent.id });
}
private buildUpdateData(
agent: AgentEntity & { roleId: string | null },
input: UpdateAgentInput,
): Partial<AgentEntity> {
const updateData: Partial<AgentEntity> = {
...agent,
...Object.fromEntries(
Object.entries(input).filter(([_, value]) => value !== undefined),
),
};
if (input.label !== undefined) {
updateData.name = computeMetadataNameFromLabel(input.label);
} else if (input.name !== undefined) {
updateData.name = input.name;
}
return updateData;
}
private async updateAgentRole(
workspaceId: string,
agentId: string,
roleId: string | null | undefined,
): Promise<void> {
if (isNonEmptyString(roleId)) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId,
roleId,
});
return;
}
await this.agentRoleService.removeRoleFromAgent({
workspaceId,
agentId,
});
}
async deleteOneAgent(id: string, workspaceId: string) {
const agent = await this.findOneAgent(workspaceId, { id });
await this.agentRepository.softDelete({ id: agent.id });
return agent;
}
}