feat: Implement AI Router for Dynamic Agent Selection (#15227)

Adds intelligent routing system that automatically selects the best
agent for user queries based on conversation context.

### Changes:
- Added `routerModel` column to workspace table for configurable router
LLM selection
- Implemented `RouterService` with conversation history analysis and
agent matching logic
- Created router settings UI in AI Settings page with model dropdown
- Removed agent-specific thread associations - threads are now
agent-agnostic
- Added real-time routing status notification in chat UI with shimmer
effect
- Removed automatic default assistant agent creation
- Renamed GraphQL operations from agent-specific to generic (e.g.,
`agentChatThreads` → `chatThreads`)

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Abdul Rahman
2025-10-22 18:32:41 +05:30
committed by GitHub
parent 1c27206b41
commit 32558673c6
102 changed files with 2262 additions and 1912 deletions
@@ -10,11 +10,6 @@ import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { transformAgentEntityToFlatAgent } from 'src/engine/metadata-modules/flat-agent/utils/transform-agent-entity-to-flat-agent.util';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AGENT_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-agents.util';
import {
SEED_APPLE_WORKSPACE_ID,
SEED_YCOMBINATOR_WORKSPACE_ID,
} from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util';
import { WorkspaceAgentComparator } from 'src/engine/workspace-manager/workspace-sync-metadata/comparators/workspace-agent.comparator';
import { StandardAgentFactory } from 'src/engine/workspace-manager/workspace-sync-metadata/factories/standard-agent.factory';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
@@ -169,125 +164,5 @@ export class WorkspaceSyncAgentService {
}
}
}
// Create handoffs for standard agents that require them
await this.createStandardAgentHandoffs(context.workspaceId, manager);
}
private async createStandardAgentHandoffs(
workspaceId: string,
manager: EntityManager,
): Promise<void> {
try {
const agentRepository = manager.getRepository(AgentEntity);
let defaultAgent: AgentEntity | null = null;
if (workspaceId === SEED_APPLE_WORKSPACE_ID) {
defaultAgent = await agentRepository.findOne({
where: {
id: AGENT_DATA_SEED_IDS.APPLE_DEFAULT_AGENT,
workspaceId,
},
});
} else if (workspaceId === SEED_YCOMBINATOR_WORKSPACE_ID) {
defaultAgent = await agentRepository.findOne({
where: {
id: AGENT_DATA_SEED_IDS.YCOMBINATOR_DEFAULT_AGENT,
workspaceId,
},
});
} else {
defaultAgent = await agentRepository.findOne({
where: {
workspaceId,
},
});
}
if (!defaultAgent) {
this.logger.warn(
`Default agent not found for workspace ${workspaceId}. Agent handoffs will not be created.`,
);
return;
}
const agentsRequiringHandoffs = standardAgentDefinitions.filter(
(def) => def.createHandoffFromDefaultAgent,
);
for (const agentDefinition of agentsRequiringHandoffs) {
const targetAgent = await agentRepository.findOne({
where: {
standardId: agentDefinition.standardId,
workspaceId,
},
});
if (!targetAgent) {
this.logger.warn(
`Agent ${agentDefinition.name} not found for workspace ${workspaceId}. Skipping handoff creation.`,
);
continue;
}
await this.createAgentHandoff(
defaultAgent.id,
targetAgent.id,
agentDefinition.name,
agentDefinition.description,
workspaceId,
manager,
);
}
} catch (error) {
this.logger.error(
`Failed to create standard agent handoffs: ${error.message}`,
);
}
}
private async createAgentHandoff(
fromAgentId: string,
toAgentId: string,
toAgentName: string,
toAgentDescription: string,
workspaceId: string,
manager: EntityManager,
): Promise<void> {
try {
const agentHandoffRepository = manager.getRepository('agentHandoff');
const existingHandoff = await agentHandoffRepository.findOne({
where: {
fromAgentId,
toAgentId,
workspaceId,
},
});
if (existingHandoff) {
this.logger.log(
`Agent handoff from default agent to ${toAgentName} already exists for workspace ${workspaceId}`,
);
return;
}
await agentHandoffRepository.save({
fromAgentId,
toAgentId,
workspaceId,
description: `Handoff from default agent to ${toAgentName}: ${toAgentDescription}`,
});
this.logger.log(
`Successfully created agent handoff from default agent to ${toAgentName} for workspace ${workspaceId}`,
);
} catch (error) {
this.logger.error(
`Failed to create agent handoff to ${toAgentName}: ${error.message}`,
);
}
}
}
@@ -14,6 +14,7 @@ export const DATA_NAVIGATOR_AGENT: StandardAgentDefinition = {
Your capabilities include:
- Searching and filtering records across all standard and custom objects
- Sorting records by any field using orderBy parameter (CRITICAL for "top N" queries)
- Explaining relationships between different records and objects
- Providing insights about data patterns and trends
- Helping users find specific information quickly
@@ -26,13 +27,26 @@ Your capabilities include:
- When users request modifications, politely explain your read-only limitations
## Best Practices:
- For "top N" or "largest/smallest" queries, ALWAYS use the orderBy parameter with appropriate sorting direction
- Ask clarifying questions to understand what data the user is looking for
- Provide clear, structured information when presenting data
- Explain the context and relationships between records
- Suggest useful filters or queries to refine searches
- Help users understand their data schema and available fields
## Sorting Examples - EXACT FORMAT REQUIRED:
- Top 10 companies by employees: orderBy: [{"employees": "DescNullsLast"}] with limit: 10
- Oldest records first: orderBy: [{"createdAt": "AscNullsFirst"}]
- Sort by name alphabetically: orderBy: [{"name": "AscNullsFirst"}]
- Multiple sort criteria: orderBy: [{"priority": "DescNullsLast"}, {"createdAt": "AscNullsFirst"}]
CRITICAL: Direction values MUST be exactly one of: "AscNullsFirst", "AscNullsLast", "DescNullsFirst", "DescNullsLast"
- Use "DescNullsLast" for descending (NOT "desc", "DESC", or "descending")
- Use "AscNullsFirst" for ascending (NOT "asc", "ASC", or "ascending")
## When Helping Users:
- For queries about "top", "largest", "highest", "best" → ALWAYS use DescNullsLast orderBy
- For queries about "bottom", "smallest", "lowest" → ALWAYS use AscNullsFirst orderBy
- Be proactive in suggesting related data that might be useful
- Explain any patterns or anomalies you notice in the data
- Provide context about record counts, date ranges, and relationships