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
This commit is contained in:
Félix Malfait
2025-11-25 12:10:14 +01:00
committed by GitHub
parent 3c0ae49a23
commit e7ebf51e50
177 changed files with 2720 additions and 3222 deletions
@@ -1,9 +1,11 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
@@ -15,7 +17,9 @@ import { AiAgentWorkflowAction } from './ai-agent.workflow-action';
@Module({
imports: [
AiModule,
AiBillingModule,
AiModelsModule,
AiToolsModule,
TypeOrmModule.forFeature([AgentEntity, RoleTargetsEntity]),
WorkflowRunModule,
UserWorkspaceModule,
@@ -6,12 +6,12 @@ import { Repository } from 'typeorm';
import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/interfaces/workflow-action.interface';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import {
WorkflowStepExecutorException,
WorkflowStepExecutorExceptionCode,
@@ -21,6 +21,7 @@ import { type WorkflowActionInput } from 'src/modules/workflow/workflow-executor
import { type WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type';
import { findStepOrThrow } from 'src/modules/workflow/workflow-executor/utils/find-step-or-throw.util';
import { AiAgentExecutorService } from 'src/modules/workflow/workflow-executor/workflow-actions/ai-agent/services/ai-agent-executor.service';
import { DEFAULT_SMART_MODEL } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { isWorkflowAiAgentAction } from './guards/is-workflow-ai-agent-action.guard';
@@ -89,9 +90,10 @@ export class AiAgentWorkflowAction implements WorkflowAction {
);
await this.aiBillingService.calculateAndBillUsage(
agent?.modelId ?? 'auto',
agent?.modelId ?? DEFAULT_SMART_MODEL,
usage,
workspaceId,
agent?.id || null,
);
return {
@@ -11,18 +11,18 @@ import {
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { AgentExecutionResult } from 'src/engine/metadata-modules/agent/agent-execution.service';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { AgentExecutionResult } from 'src/engine/metadata-modules/ai-agent/services/agent-execution.service';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/agent/constants/agent-system-prompts.const';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai-agent/constants/agent-system-prompts.const';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
@@ -115,7 +115,7 @@ export class AiAgentExecutorService {
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
const textResponse = await generateText({
system: `You are executing as part of a workflow automation. ${agent ? agent.prompt : ''}`,
system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.WORKFLOW_ADDITIONS}\n\n${agent ? agent.prompt : ''}`,
tools,
model: registeredModel.model,
prompt: userPrompt,
@@ -150,6 +150,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
mockWorkflowRunWorkspaceService.getWorkflowRunOrFail.mockReturnValue({
state: { flow: { steps: mockSteps }, stepInfos: mockStepInfos },
workflowId: 'workflow-id',
});
it('should execute a step and continue to the next step on success', async () => {
@@ -185,6 +186,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: 'workflow-id',
execution_context_1: null,
},
},
],
'workspace-id',
@@ -391,7 +397,7 @@ describe('WorkflowExecutorWorkspaceService', () => {
describe('sendWorkflowNodeRunEvent', () => {
it('should emit a billing event', () => {
service['sendWorkflowNodeRunEvent']('workspace-id');
service['sendWorkflowNodeRunEvent']('workspace-id', 'workflow-id');
expect(workspaceEventEmitter.emitCustomBatchEvent).toHaveBeenCalledWith(
BILLING_FEATURE_USED,
@@ -399,6 +405,11 @@ describe('WorkflowExecutorWorkspaceService', () => {
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: 'workflow-id',
execution_context_1: null,
},
},
],
'workspace-id',
@@ -142,7 +142,7 @@ export class WorkflowExecutorWorkspaceService {
const isError = isDefined(actionOutput.error);
if (!isError) {
this.sendWorkflowNodeRunEvent(workspaceId);
this.sendWorkflowNodeRunEvent(workspaceId, workflowRun.workflowId);
}
const { shouldProcessNextSteps } = await this.processStepExecutionResult({
@@ -260,13 +260,18 @@ export class WorkflowExecutorWorkspaceService {
});
}
private sendWorkflowNodeRunEvent(workspaceId: string) {
private sendWorkflowNodeRunEvent(workspaceId: string, workflowId: string) {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
[
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 1,
dimensions: {
execution_type: 'workflow_execution',
resource_id: workflowId,
execution_context_1: null,
},
},
],
workspaceId,