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
@@ -0,0 +1,180 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
describe('McpCoreController', () => {
let controller: McpCoreController;
let mcpProtocolService: jest.Mocked<McpProtocolService>;
beforeEach(async () => {
const mockMcpProtocolService = {
handleMCPCoreQuery: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [McpCoreController],
providers: [
{
provide: McpProtocolService,
useValue: mockMcpProtocolService,
},
{
provide: AccessTokenService,
useValue: jest.fn(),
},
{
provide: WorkspaceCacheStorageService,
useValue: jest.fn(),
},
{
provide: HttpExceptionHandlerService,
useValue: {
handleError: jest.fn(),
},
},
],
}).compile();
controller = module.get<McpCoreController>(McpCoreController);
mcpProtocolService = module.get(McpProtocolService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('handleMcpCore', () => {
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = 'api-key-1';
it('should call mcpProtocolService.handleMCPCoreQuery with correct parameters', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const mockResponse = {
id: '123',
jsonrpc: '2.0',
result: {
content: [{ type: 'text', text: '{"result":"success"}' }],
isError: false,
},
};
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
mockWorkspace,
mockApiKey,
mockUserWorkspaceId,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
it('should handle initialize method', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'initialize',
id: '123',
};
const mockResponse = {
id: '123',
jsonrpc: '2.0',
result: {
...MCP_SERVER_METADATA,
capabilities: {
tools: { listChanged: false },
resources: { listChanged: false },
prompts: { listChanged: false },
},
},
};
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
mockWorkspace,
mockApiKey,
mockUserWorkspaceId,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
it('should handle tools listing', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
const mockResponse = {
id: '123',
jsonrpc: '2.0',
result: {
...MCP_SERVER_METADATA,
capabilities: {
tools: { listChanged: false },
},
tools: [
{
name: 'testTool',
description: 'A test tool',
inputSchema: { type: 'object', properties: {} },
},
],
},
};
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
mockWorkspace,
mockApiKey,
mockUserWorkspaceId,
);
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
});
});
@@ -0,0 +1,48 @@
import {
Body,
Controller,
Post,
UseFilters,
UseGuards,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
import { JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('mcp')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(RestApiExceptionFilter)
export class McpCoreController {
constructor(private readonly mcpProtocolService: McpProtocolService) {}
@Post()
@UsePipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
)
async handleMcpCore(
@Body() body: JsonRpc,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthApiKey() apiKey: string | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
return await this.mcpProtocolService.handleMCPCoreQuery(body, {
workspace,
userWorkspaceId,
apiKey,
});
}
}