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,30 +1,29 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
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/core-modules/ai/constants/mcp.const';
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';
import { McpController } from './mcp.controller';
describe('McpController', () => {
let controller: McpController;
let mcpService: jest.Mocked<McpService>;
describe('McpCoreController', () => {
let controller: McpCoreController;
let mcpProtocolService: jest.Mocked<McpProtocolService>;
beforeEach(async () => {
const mockMcpService = {
const mockMcpProtocolService = {
handleMCPCoreQuery: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [McpController],
controllers: [McpCoreController],
providers: [
{
provide: McpService,
useValue: mockMcpService,
provide: McpProtocolService,
useValue: mockMcpProtocolService,
},
{
provide: AccessTokenService,
@@ -43,8 +42,8 @@ describe('McpController', () => {
],
}).compile();
controller = module.get<McpController>(McpController);
mcpService = module.get(McpService);
controller = module.get<McpCoreController>(McpCoreController);
mcpProtocolService = module.get(McpProtocolService);
});
it('should be defined', () => {
@@ -56,7 +55,7 @@ describe('McpController', () => {
const mockUserWorkspaceId = 'user-workspace-1';
const mockApiKey = 'api-key-1';
it('should call mcpService.handleMCPCoreQuery with correct parameters', async () => {
it('should call mcpProtocolService.handleMCPCoreQuery with correct parameters', async () => {
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
@@ -73,7 +72,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -82,11 +81,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
@@ -110,7 +112,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -119,11 +121,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
@@ -152,7 +157,7 @@ describe('McpController', () => {
},
};
mcpService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
mcpProtocolService.handleMCPCoreQuery.mockResolvedValue(mockResponse);
const result = await controller.handleMcpCore(
mockRequest,
@@ -161,11 +166,14 @@ describe('McpController', () => {
mockUserWorkspaceId,
);
expect(mcpService.handleMCPCoreQuery).toHaveBeenCalledWith(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
});
expect(mcpProtocolService.handleMCPCoreQuery).toHaveBeenCalledWith(
mockRequest,
{
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
apiKey: mockApiKey,
},
);
expect(result).toEqual(mockResponse);
});
});
@@ -8,9 +8,9 @@ import {
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 { JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
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';
@@ -22,8 +22,8 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('mcp')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard, NoPermissionGuard)
@UseFilters(RestApiExceptionFilter)
export class McpController {
constructor(private readonly mcpService: McpService) {}
export class McpCoreController {
constructor(private readonly mcpProtocolService: McpProtocolService) {}
@Post()
@UsePipes(
@@ -39,7 +39,7 @@ export class McpController {
@AuthApiKey() apiKey: string | undefined,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
) {
return await this.mcpService.handleMCPCoreQuery(body, {
return await this.mcpProtocolService.handleMCPCoreQuery(body, {
workspace,
userWorkspaceId,
apiKey,
@@ -8,7 +8,7 @@ import {
Validate,
} from 'class-validator';
import { IsNumberOrString } from 'src/engine/core-modules/ai/decorators/string-or-number';
import { IsNumberOrString } from 'src/engine/api/mcp/decorators/string-or-number';
export class JsonRpc {
@IsString()
@@ -1,36 +1,43 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { McpCoreController } from 'src/engine/api/mcp/controllers/mcp-core.controller';
import { McpMetadataController } from 'src/engine/api/mcp/controllers/mcp-metadata.controller';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { MCPMetadataService } from 'src/engine/api/mcp/services/mcp-metadata.service';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.tools.service';
import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service';
import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service';
import { MCPMetadataToolsService } from 'src/engine/api/mcp/services/tools/mcp-metadata-tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { RestApiModule } from 'src/engine/api/rest/rest-api.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
TypeOrmModule.forFeature([RoleEntity]),
AiModule,
AiToolsModule,
TokenModule,
WorkspaceCacheStorageModule,
FeatureFlagModule,
RestApiModule,
MetadataQueryBuilderModule,
MetricsModule,
UserRoleModule,
],
controllers: [McpMetadataController],
exports: [],
controllers: [McpCoreController, McpMetadataController],
exports: [McpProtocolService],
providers: [
McpProtocolService,
McpToolExecutorService,
MCPMetadataService,
MCPMetadataToolsService,
CreateToolsService,
@@ -4,10 +4,11 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { jsonSchema } from 'ai';
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { McpService } from 'src/engine/core-modules/ai/services/mcp.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpProtocolService } from 'src/engine/api/mcp/services/mcp-protocol.service';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -15,11 +16,12 @@ import { ADMIN_ROLE_LABEL } from 'src/engine/metadata-modules/permissions/consta
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
describe('McpService', () => {
let service: McpService;
describe('McpProtocolService', () => {
let service: McpProtocolService;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let toolService: jest.Mocked<ToolService>;
let userRoleService: jest.Mocked<UserRoleService>;
let mcpToolExecutorService: jest.Mocked<McpToolExecutorService>;
const mockWorkspace = { id: 'workspace-1' } as WorkspaceEntity;
const mockUserWorkspaceId = 'user-workspace-1';
@@ -40,6 +42,11 @@ describe('McpService', () => {
getRoleIdForUserWorkspace: jest.fn(),
};
const mockMcpToolExecutorService = {
handleToolCall: jest.fn(),
handleToolsListing: jest.fn(),
};
const mockAdminRole = {
id: mockAdminRoleId,
label: ADMIN_ROLE_LABEL,
@@ -47,7 +54,7 @@ describe('McpService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
McpService,
McpProtocolService,
{
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
@@ -60,6 +67,10 @@ describe('McpService', () => {
provide: UserRoleService,
useValue: mockUserRoleService,
},
{
provide: McpToolExecutorService,
useValue: mockMcpToolExecutorService,
},
{
provide: getRepositoryToken(RoleEntity),
useValue: {
@@ -69,10 +80,11 @@ describe('McpService', () => {
],
}).compile();
service = module.get<McpService>(McpService);
service = module.get<McpProtocolService>(McpProtocolService);
featureFlagService = module.get(FeatureFlagService);
toolService = module.get(ToolService);
userRoleService = module.get(UserRoleService);
mcpToolExecutorService = module.get(McpToolExecutorService);
});
it('should be defined', () => {
@@ -209,19 +221,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toEqual({
const mockToolCallResponse = {
id: '123',
jsonrpc: '2.0',
result: {
@@ -234,11 +234,29 @@ describe('McpService', () => {
],
isError: false,
},
};
mcpToolExecutorService.handleToolCall.mockResolvedValue(
mockToolCallResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(mockTool.execute).toHaveBeenCalledWith(
{ arg1: 'value1' },
{ toolCallId: '1', messages: [] },
expect(result).toEqual(mockToolCallResponse);
expect(mcpToolExecutorService.handleToolCall).toHaveBeenCalledWith(
'123',
mockToolsMap,
{ name: 'testTool', arguments: { arg1: 'value1' } },
);
});
@@ -257,19 +275,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
apiKey: mockApiKey,
});
expect(result).toEqual({
const mockToolCallResponse = {
id: '123',
jsonrpc: '2.0',
result: {
@@ -282,16 +288,29 @@ describe('McpService', () => {
],
isError: false,
},
};
mcpToolExecutorService.handleToolCall.mockResolvedValue(
mockToolCallResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
params: { name: 'testTool', arguments: { arg1: 'value1' } },
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
apiKey: mockApiKey,
});
expect(result).toEqual(mockToolCallResponse);
expect(toolService.listTools).toHaveBeenCalledWith(
{ unionOf: [mockAdminRoleId] },
mockWorkspace.id,
);
expect(mockTool.execute).toHaveBeenCalledWith(
{ arg1: 'value1' },
{ toolCallId: '1', messages: [] },
);
});
it('should handle tools listing', async () => {
@@ -307,18 +326,7 @@ describe('McpService', () => {
toolService.listTools.mockResolvedValue(mockToolsMap);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toMatchObject({
const mockToolsListingResponse = {
id: '123',
jsonrpc: '2.0',
result: expect.objectContaining({
@@ -334,7 +342,24 @@ describe('McpService', () => {
},
],
}),
};
mcpToolExecutorService.handleToolsListing.mockReturnValue(
mockToolsListingResponse,
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/list',
id: '123',
};
const result = await service.handleMCPCoreQuery(mockRequest, {
workspace: mockWorkspace,
userWorkspaceId: mockUserWorkspaceId,
});
expect(result).toMatchObject(mockToolsListingResponse);
});
it('should handle error when AI is disabled', async () => {
@@ -367,6 +392,13 @@ describe('McpService', () => {
userRoleService.getRoleIdForUserWorkspace.mockResolvedValue(mockRoleId);
toolService.listTools.mockResolvedValue({});
mcpToolExecutorService.handleToolCall.mockRejectedValue(
new HttpException(
"Tool 'nonExistentTool' not found",
HttpStatus.NOT_FOUND,
),
);
const mockRequest: JsonRpc = {
jsonrpc: '2.0',
method: 'tools/call',
@@ -8,7 +8,7 @@ import { CreateToolsService } from 'src/engine/api/mcp/services/tools/create.too
import { DeleteToolsService } from 'src/engine/api/mcp/services/tools/delete.tools.service';
import { GetToolsService } from 'src/engine/api/mcp/services/tools/get.tools.service';
import { UpdateToolsService } from 'src/engine/api/mcp/services/tools/update.tools.service';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
@@ -1,13 +1,12 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { type JsonRpc } from 'src/engine/core-modules/ai/dtos/json-rpc';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.service';
import { wrapJsonRpcResponse } from 'src/engine/core-modules/ai/utils/wrap-jsonrpc-response.util';
import { type JsonRpc } from 'src/engine/api/mcp/dtos/json-rpc';
import { McpToolExecutorService } from 'src/engine/api/mcp/services/mcp-tool-executor.service';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
import { ToolService } from 'src/engine/metadata-modules/ai-tools/services/tool.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
@@ -16,11 +15,12 @@ import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role
import { ADMIN_ROLE } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-roles/roles/admin-role';
@Injectable()
export class McpService {
export class McpProtocolService {
constructor(
private readonly featureFlagService: FeatureFlagService,
private readonly toolService: ToolService,
private readonly userRoleService: UserRoleService,
private readonly mcpToolExecutorService: McpToolExecutorService,
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
) {}
@@ -134,11 +134,15 @@ export class McpService {
);
if (method === 'tools/call' && params) {
return await this.handleToolCall(id, toolSet, params);
return await this.mcpToolExecutorService.handleToolCall(
id,
toolSet,
params,
);
}
if (method === 'tools/list') {
return await this.handleToolsListing(id, toolSet);
return this.mcpToolExecutorService.handleToolsListing(id, toolSet);
}
if (method === 'prompts/list') {
@@ -175,70 +179,4 @@ export class McpService {
});
}
}
private async handleToolCall(
id: string | number,
toolSet: ToolSet,
params: Record<string, unknown>,
) {
const toolName = params.name as keyof typeof toolSet;
const tool = toolSet[toolName];
if (isDefined(tool) && isDefined(tool.execute)) {
return wrapJsonRpcResponse(id, {
result: {
content: [
{
type: 'text',
text: JSON.stringify(
await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
}),
),
},
],
isError: false,
},
});
}
throw new HttpException(
`Tool '${params.name}' not found`,
HttpStatus.NOT_FOUND,
);
}
private handleToolsListing(id: string | number, toolSet: ToolSet) {
const toolsArray = Object.entries(toolSet)
.filter(([, def]) => !!def.inputSchema)
.map(([name, def]) => {
// Unwrap the AI SDK's jsonSchema wrapper if present
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
const inputSchema = def.inputSchema;
const unwrappedSchema =
inputSchema &&
typeof inputSchema === 'object' &&
'jsonSchema' in inputSchema
? inputSchema.jsonSchema
: inputSchema;
return {
name,
description: def.description,
inputSchema: unwrappedSchema,
};
});
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
tools: { listChanged: false },
},
tools: toolsArray,
resources: [],
prompts: [],
},
});
}
}
@@ -0,0 +1,75 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import { isDefined } from 'twenty-shared/utils';
import { wrapJsonRpcResponse } from 'src/engine/api/mcp/utils/wrap-jsonrpc-response.util';
@Injectable()
export class McpToolExecutorService {
async handleToolCall(
id: string | number,
toolSet: ToolSet,
params: Record<string, unknown>,
) {
const toolName = params.name as keyof typeof toolSet;
const tool = toolSet[toolName];
if (isDefined(tool) && isDefined(tool.execute)) {
return wrapJsonRpcResponse(id, {
result: {
content: [
{
type: 'text',
text: JSON.stringify(
await tool.execute(params.arguments, {
toolCallId: '1',
messages: [],
}),
),
},
],
isError: false,
},
});
}
throw new HttpException(
`Tool '${params.name}' not found`,
HttpStatus.NOT_FOUND,
);
}
handleToolsListing(id: string | number, toolSet: ToolSet) {
const toolsArray = Object.entries(toolSet)
.filter(([, def]) => !!def.inputSchema)
.map(([name, def]) => {
// Unwrap the AI SDK's jsonSchema wrapper if present
// The AI SDK serializes schemas as { jsonSchema: {...} } but MCP expects {...} directly
const inputSchema = def.inputSchema;
const unwrappedSchema =
inputSchema &&
typeof inputSchema === 'object' &&
'jsonSchema' in inputSchema
? inputSchema.jsonSchema
: inputSchema;
return {
name,
description: def.description,
inputSchema: unwrappedSchema,
};
});
return wrapJsonRpcResponse(id, {
result: {
capabilities: {
tools: { listChanged: false },
},
tools: toolsArray,
resources: [],
prompts: [],
},
});
}
}
@@ -1,4 +1,4 @@
import { MCP_SERVER_METADATA } from 'src/engine/core-modules/ai/constants/mcp.const';
import { MCP_SERVER_METADATA } from 'src/engine/api/mcp/constants/mcp.const';
export const wrapJsonRpcResponse = (
id: string | number,
@@ -1,190 +0,0 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { openai } from '@ai-sdk/openai';
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { AiController } from './ai.controller';
describe('AiController', () => {
let controller: AiController;
let aiService: jest.Mocked<AiService>;
let featureFlagService: jest.Mocked<FeatureFlagService>;
let aiBillingService: jest.Mocked<AIBillingService>;
let aiModelRegistryService: jest.Mocked<AiModelRegistryService>;
beforeEach(async () => {
const mockAiService = {
streamText: jest.fn(),
getModel: jest.fn(),
};
const mockFeatureFlagService = {
isFeatureEnabled: jest.fn().mockResolvedValue(true),
};
const mockAIBillingService = {
calculateAndBillUsage: jest.fn(),
};
const mockAiModelRegistryService = {
getDefaultPerformanceModel: jest.fn().mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: openai('gpt-4o'),
}),
};
const module: TestingModule = await Test.createTestingModule({
controllers: [AiController],
providers: [
{
provide: AiService,
useValue: mockAiService,
},
{
provide: FeatureFlagService,
useValue: mockFeatureFlagService,
},
{
provide: AIBillingService,
useValue: mockAIBillingService,
},
{
provide: AiModelRegistryService,
useValue: mockAiModelRegistryService,
},
],
}).compile();
controller = module.get<AiController>(AiController);
aiService = module.get(AiService);
featureFlagService = module.get(FeatureFlagService);
aiBillingService = module.get(AIBillingService);
aiModelRegistryService = module.get(AiModelRegistryService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
describe('chat', () => {
const mockWorkspace = { id: 'workspace-1' } as any;
it('should handle valid chat request and bill usage', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
temperature: 0.7,
maxOutputTokens: 100,
};
const mockRes = {
setHeader: jest.fn(),
write: jest.fn(),
end: jest.fn(),
} as any;
const mockModel = openai('gpt-4o');
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: mockModel,
});
const mockUsage = {
inputTokens: 10,
outputTokens: 20,
totalTokens: 30,
};
const mockStreamTextResult = {
usage: Promise.resolve(mockUsage),
pipeUIMessageStreamToResponse: jest.fn(),
};
aiService.streamText.mockReturnValue(mockStreamTextResult as any);
await controller.chat(mockRequest, mockWorkspace, mockRes);
// Wait a microtask so the usage.then billing call fires
await Promise.resolve();
expect(featureFlagService.isFeatureEnabled).toHaveBeenCalled();
expect(aiService.streamText).toHaveBeenCalledWith({
messages: mockRequest.messages,
options: {
temperature: 0.7,
maxOutputTokens: 100,
model: mockModel,
},
});
expect(
mockStreamTextResult.pipeUIMessageStreamToResponse,
).toHaveBeenCalledWith(mockRes);
expect(aiBillingService.calculateAndBillUsage).toHaveBeenCalledWith(
mockModel.modelId,
mockUsage,
mockWorkspace.id,
);
});
it('should throw error for empty messages', async () => {
const mockRequest = {
messages: [],
};
const mockRes = {} as any;
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('Messages array is required and cannot be empty');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should handle service errors', async () => {
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
};
const mockRes = {} as any;
aiModelRegistryService.getDefaultPerformanceModel.mockReturnValue({
modelId: 'gpt-4o',
provider: ModelProvider.OPENAI,
model: openai('gpt-4o'),
});
aiService.streamText.mockImplementation(() => {
throw new Error('Service error');
});
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow(
'An error occurred while processing your request: Service error',
);
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
it('should throw error when AI feature is disabled', async () => {
featureFlagService.isFeatureEnabled.mockResolvedValue(false);
const mockRequest = {
messages: [{ role: 'user' as const, content: 'Hello' }],
};
const mockRes = {} as any;
await expect(
controller.chat(mockRequest, mockWorkspace, mockRes),
).rejects.toThrow('AI feature is not enabled for this workspace');
expect(aiBillingService.calculateAndBillUsage).not.toHaveBeenCalled();
});
});
});
@@ -1,99 +0,0 @@
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { type ModelMessage } from 'ai';
import { Response } from 'express';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiService } from 'src/engine/core-modules/ai/services/ai.service';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { CustomPermissionGuard } from 'src/engine/guards/custom-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
interface ChatRequest {
messages: ModelMessage[];
temperature?: number;
maxOutputTokens?: number;
}
@Controller('chat')
@UseGuards(WorkspaceAuthGuard, CustomPermissionGuard)
export class AiController {
constructor(
private readonly aiService: AiService,
private readonly featureFlagService: FeatureFlagService,
private readonly aiBillingService: AIBillingService,
private readonly aiModelRegistryService: AiModelRegistryService,
) {}
@Post()
async chat(
@Body() request: ChatRequest,
@AuthWorkspace() workspace: WorkspaceEntity,
@Res() res: Response,
) {
const isAiEnabled = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_AI_ENABLED,
workspace.id,
);
if (!isAiEnabled) {
throw new HttpException(
'AI feature is not enabled for this workspace',
HttpStatus.FORBIDDEN,
);
}
const { messages, temperature, maxOutputTokens } = request;
if (!messages || messages.length === 0) {
throw new HttpException(
'Messages array is required and cannot be empty',
HttpStatus.BAD_REQUEST,
);
}
try {
const registeredModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const result = this.aiService.streamText({
messages,
options: {
temperature,
maxOutputTokens,
model: registeredModel.model,
},
});
result.usage.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
registeredModel.modelId,
usage,
workspace.id,
);
});
result.pipeUIMessageStreamToResponse(res);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
throw new HttpException(
`An error occurred while processing your request: ${errorMessage}`,
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
@@ -1,120 +0,0 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import {
buildWhereConditions,
parseFilterCondition,
} from 'src/engine/core-modules/ai/utils/find-records-filters.utils';
describe('find-records-filters.utils', () => {
describe('parseFilterCondition', () => {
it('should handle eq', () => {
expect(parseFilterCondition({ eq: 10 })).toBe(10);
});
it('should handle neq', () => {
expect(parseFilterCondition({ neq: 5 })).toEqual(Not(5));
});
it('should handle gt/gte/lt/lte', () => {
expect(parseFilterCondition({ gt: 1 })).toEqual(MoreThan(1));
expect(parseFilterCondition({ gte: 2 })).toEqual(MoreThanOrEqual(2));
expect(parseFilterCondition({ lt: 3 })).toEqual(LessThan(3));
expect(parseFilterCondition({ lte: 4 })).toEqual(LessThanOrEqual(4));
});
it('should handle in', () => {
expect(parseFilterCondition({ in: ['a', 'b'] })).toEqual(In(['a', 'b']));
});
it('should handle like/ilike', () => {
expect(parseFilterCondition({ like: '%foo%' })).toEqual(Like('%foo%'));
expect(parseFilterCondition({ ilike: '%bar%' })).toEqual(ILike('%bar%'));
});
it('should handle startsWith', () => {
expect(parseFilterCondition({ startsWith: 'pre' })).toEqual(Like('pre%'));
});
it('should handle is NULL and NOT_NULL', () => {
expect(parseFilterCondition({ is: 'NULL' })).toEqual(IsNull());
expect(parseFilterCondition({ is: 'NOT_NULL' })).toEqual(Not(IsNull()));
});
it('should handle isEmptyArray', () => {
expect(parseFilterCondition({ isEmptyArray: true })).toEqual([]);
});
it('should handle containsIlike (uses Like with wildcards)', () => {
const result = parseFilterCondition({ containsIlike: 'mid' });
expect(result).toEqual(Like('%mid%'));
});
});
describe('buildWhereConditions', () => {
it('should build where conditions from mixed criteria', () => {
const where = buildWhereConditions({
// primitive values
id: '123',
active: true,
count: 7,
// skip falsy-empty values
skipUndefined: undefined,
skipNull: null,
skipEmptyString: '',
// operator objects
name: { ilike: '%alpha%' },
createdAt: { gte: '2024-01-01' },
score: { lte: 99 },
tags: { in: ['a', 'b'] },
prefix: { startsWith: 'PRE' },
nothing: { is: 'NULL' },
notNothing: { is: 'NOT_NULL' },
contains: { containsIlike: 'mid' },
// nested relation-like object
account: {
name: { startsWith: 'ACME' },
size: { gte: 100 },
country: '', // should be omitted
},
// arrays should pass through when not an operator object
list: [1, 2, 3],
});
expect(where.id).toBe('123');
expect(where.active).toBe(true);
expect(where.count).toBe(7);
expect(where.name).toEqual(ILike('%alpha%'));
expect(where.createdAt).toEqual(MoreThanOrEqual('2024-01-01'));
expect(where.score).toEqual(LessThanOrEqual(99));
expect(where.tags).toEqual(In(['a', 'b']));
expect(where.prefix).toEqual(Like('PRE%'));
expect(where.nothing).toEqual(IsNull());
expect(where.notNothing).toEqual(Not(IsNull()));
expect(where.contains).toEqual(Like('%mid%'));
expect(where.account).toEqual({
name: Like('ACME%'),
size: MoreThanOrEqual(100),
});
expect(where.list).toEqual([1, 2, 3]);
// Ensure skipped values are not present
expect('skipUndefined' in where).toBe(false);
expect('skipNull' in where).toBe(false);
expect('skipEmptyString' in where).toBe(false);
});
});
});
@@ -1,112 +0,0 @@
import {
ILike,
In,
IsNull,
LessThan,
LessThanOrEqual,
Like,
MoreThan,
MoreThanOrEqual,
Not,
} from 'typeorm';
import { isDefined } from 'twenty-shared/utils';
type FilterObject = Record<string, unknown>;
type WhereRecord = Record<string, unknown>;
const isSkippableValue = (value: unknown): boolean =>
!isDefined(value) || value === '';
const isPlainObject = (value: unknown): value is FilterObject =>
typeof value === 'object' && value !== null && !Array.isArray(value);
export const parseFilterCondition = (filterValue: FilterObject): unknown => {
if ('eq' in filterValue) {
return filterValue.eq;
}
if ('neq' in filterValue) {
return Not(filterValue.neq as unknown);
}
if ('gt' in filterValue) {
return MoreThan(filterValue.gt as unknown as number | string | Date);
}
if ('gte' in filterValue) {
return MoreThanOrEqual(
filterValue.gte as unknown as number | string | Date,
);
}
if ('lt' in filterValue) {
return LessThan(filterValue.lt as unknown as number | string | Date);
}
if ('lte' in filterValue) {
return LessThanOrEqual(
filterValue.lte as unknown as number | string | Date,
);
}
if ('in' in filterValue) {
const values = (filterValue as { in: unknown }).in;
return Array.isArray(values) ? In(values as unknown[]) : null;
}
if ('like' in filterValue) {
return Like(filterValue.like as string);
}
if ('ilike' in filterValue) {
return ILike(filterValue.ilike as string);
}
if ('startsWith' in filterValue) {
return Like(`${String(filterValue.startsWith)}%`);
}
if ('is' in filterValue) {
const v = (filterValue as { is: unknown }).is;
if (v === 'NULL') return IsNull();
if (v === 'NOT_NULL') return Not(IsNull());
}
if ('isEmptyArray' in filterValue) {
return [];
}
if ('containsIlike' in filterValue) {
return Like(`%${String(filterValue.containsIlike)}%`);
}
return null;
};
export const buildWhereConditions = (
searchCriteria: FilterObject,
): WhereRecord => {
return Object.entries(searchCriteria).reduce<WhereRecord>(
(acc, [key, value]) => {
if (isSkippableValue(value)) {
return acc;
}
if (isPlainObject(value)) {
// Direct operator-based condition (eq, ilike, etc.)
const filterCondition = parseFilterCondition(value as FilterObject);
if (isDefined(filterCondition)) {
acc[key] = filterCondition;
return acc;
}
// Otherwise, try to build nested conditions
const nestedConditions = buildWhereConditions(value);
if (Object.keys(nestedConditions).length > 0) {
acc[key] = nestedConditions;
}
return acc;
}
acc[key] = value as unknown;
return acc;
},
{},
);
};
@@ -16,7 +16,7 @@ import {
import { ApplicationVariableEntity } from 'src/engine/core-modules/applicationVariable/application-variable.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
@@ -5,7 +5,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceFlatApplicationMapCacheService } from 'src/engine/core-modules/application/services/workspace-flat-application-map-cache.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@Module({
@@ -10,7 +10,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/applicationVariable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/agent/dtos/agent.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai-agent/dtos/agent.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
@@ -3,7 +3,9 @@
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 { BillingResolver } from 'src/engine/core-modules/billing/billing.resolver';
import { BillingSyncCustomerDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-customer-data.command';
import { BillingSyncPlansDataCommand } from 'src/engine/core-modules/billing/commands/billing-sync-plans-data.command';
@@ -42,7 +44,9 @@ import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permi
StripeModule,
MessageQueueModule,
PermissionsModule,
AiModule,
AiBillingModule,
AiModelsModule,
AiToolsModule,
WorkspaceDomainsModule,
TypeOrmModule.forFeature([
BillingSubscriptionEntity,
@@ -71,6 +71,7 @@ export class BillingUsageService {
eventName: billingEvents[0].eventName,
value: billingEvents[0].value,
stripeCustomerId: workspaceStripeCustomer.stripeCustomerId,
dimensions: billingEvents[0].dimensions,
});
} catch (error) {
throw new BillingException(
@@ -5,6 +5,7 @@ import { Injectable, Logger } from '@nestjs/common';
import type Stripe from 'stripe';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
import { StripeSDKService } from 'src/engine/core-modules/billing/stripe/stripe-sdk/services/stripe-sdk.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -29,17 +30,33 @@ export class StripeBillingMeterEventService {
eventName,
value,
stripeCustomerId,
dimensions,
}: {
eventName: BillingMeterEventName;
value: number;
stripeCustomerId: string;
dimensions?: BillingDimensions;
}) {
const payload: Record<string, string> = {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
};
if (dimensions) {
payload.execution_type = dimensions.execution_type;
if (dimensions.resource_id !== undefined) {
payload.resource_id = dimensions.resource_id || 'none';
}
if (dimensions.execution_context_1 !== undefined) {
payload.execution_context_1 = dimensions.execution_context_1 || 'none';
}
}
await this.stripe.billing.meterEvents.create({
event_name: eventName,
payload: {
value: value.toString(),
stripe_customer_id: stripeCustomerId,
},
payload,
});
}
@@ -0,0 +1,12 @@
/* @license Enterprise */
export type BillingExecutionType =
| 'workflow_execution'
| 'code_execution'
| 'ai_token';
export type BillingDimensions = {
execution_type: BillingExecutionType;
resource_id?: string | null;
execution_context_1?: string | null;
};
@@ -3,8 +3,10 @@
import { type NonNegative } from 'type-fest';
import { type BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingDimensions } from 'src/engine/core-modules/billing/types/billing-dimensions.type';
export type BillingUsageEvent = {
eventName: BillingMeterEventName;
value: NonNegative<number>;
dimensions?: BillingDimensions;
};
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import {
type ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { ClientConfigController } from './client-config.controller';
@@ -5,7 +5,7 @@ import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/
import {
ModelId,
ModelProvider,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { BillingTrialPeriodDTO } from 'src/engine/core-modules/billing/dtos/billing-trial-period.dto';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
@@ -3,7 +3,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { CaptchaDriverType } from 'src/engine/core-modules/captcha/interfaces';
import { ClientConfigService } from 'src/engine/core-modules/client-config/services/client-config.service';
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
@@ -5,12 +5,6 @@ import { isNonEmptyString } from '@sniptt/guards';
import { NodeEnvironment } from 'src/engine/core-modules/twenty-config/interfaces/node-environment.interface';
import { SupportDriver } from 'src/engine/core-modules/twenty-config/interfaces/support.interface';
import {
AI_MODELS,
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 { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
import {
type ClientAIModelConfig,
type ClientConfig,
@@ -18,6 +12,14 @@ import {
import { DomainServerConfigService } from 'src/engine/core-modules/domain/domain-server-config/services/domain-server-config.service';
import { PUBLIC_FEATURE_FLAGS } from 'src/engine/core-modules/feature-flag/constants/public-feature-flag.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
ModelProvider,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
@Injectable()
export class ClientConfigService {
@@ -62,13 +64,42 @@ export class ClientConfigService {
);
if (aiModels.length > 0) {
aiModels.unshift({
modelId: 'auto',
label: 'Auto',
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
});
const defaultSpeedModel =
this.aiModelRegistryService.getDefaultSpeedModel();
const defaultSpeedModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultSpeedModel?.modelId,
);
const defaultSpeedModelLabel =
defaultSpeedModelConfig?.label ||
defaultSpeedModel?.modelId ||
'Default';
const defaultPerformanceModel =
this.aiModelRegistryService.getDefaultPerformanceModel();
const defaultPerformanceModelConfig = AI_MODELS.find(
(m) => m.modelId === defaultPerformanceModel?.modelId,
);
const defaultPerformanceModelLabel =
defaultPerformanceModelConfig?.label ||
defaultPerformanceModel?.modelId ||
'Default';
aiModels.unshift(
{
modelId: DEFAULT_SMART_MODEL,
label: `Smart (${defaultPerformanceModelLabel})`,
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
},
{
modelId: DEFAULT_FAST_MODEL,
label: `Fast (${defaultSpeedModelLabel})`,
provider: ModelProvider.NONE,
inputCostPer1kTokensInCredits: 0,
outputCostPer1kTokensInCredits: 0,
},
);
}
const clientConfig: ClientConfig = {
@@ -5,7 +5,9 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { WorkspaceQueryRunnerModule } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-runner.module';
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
import { AdminPanelModule } from 'src/engine/core-modules/admin-panel/admin-panel.module';
import { AiModule } from 'src/engine/core-modules/ai/ai.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 { AiBillingModule } from 'src/engine/metadata-modules/ai-billing/ai-billing.module';
import { ApiKeyModule } from 'src/engine/core-modules/api-key/api-key.module';
import { AppTokenModule } from 'src/engine/core-modules/app-token/app-token.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
@@ -127,7 +129,9 @@ import { FileModule } from './file/file.module';
wildcard: true,
}),
CacheStorageModule,
AiModule,
AiModelsModule,
AiToolsModule,
AiBillingModule,
ServerlessModule.forRootAsync({
useFactory: serverlessModuleFactory,
inject: [TwentyConfigService, FileStorageService],
@@ -19,5 +19,7 @@ export const getSelectedColumnsFromRestrictedFields = (
},
});
return Object.keys(selectableFields);
return Object.keys(selectableFields).filter(
(columnName) => selectableFields[columnName],
);
};
@@ -1,8 +1,8 @@
import {
FieldMetadataType,
type RestrictedFieldsPermissions,
type FieldMetadataSettings,
FieldMetadataType,
NumberDataType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { z } from 'zod';
@@ -31,7 +31,7 @@ const isFieldAvailable = (field: FieldMetadataEntity, forResponse: boolean) => {
const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
switch (field.type) {
case FieldMetadataType.UUID:
return z.string().uuid();
return z.uuidv4();
case FieldMetadataType.TEXT:
case FieldMetadataType.RICH_TEXT:
@@ -41,7 +41,7 @@ const getFieldZodType = (field: FieldMetadataEntity): z.ZodTypeAny => {
return z.string().datetime();
case FieldMetadataType.DATE:
return z.string().date();
return z.date();
case FieldMetadataType.NUMBER: {
const settings =
@@ -95,7 +95,11 @@ export const generateRecordPropertiesZodSchema = (
isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION) &&
field.settings?.relationType === RelationType.MANY_TO_ONE
) {
shape[`${field.name}Id`] = z.string().uuid();
const uuidSchema = z.uuidv4();
shape[`${field.name}Id`] = field.isNullable
? uuidSchema.optional()
: uuidSchema;
return;
}
@@ -248,6 +252,10 @@ export const generateRecordPropertiesZodSchema = (
fieldSchema = fieldSchema.describe(field.description);
}
if (field.isNullable) {
fieldSchema = fieldSchema.optional();
}
shape[field.name] = fieldSchema;
});
@@ -26,7 +26,7 @@ import {
} from 'src/engine/core-modules/search/exceptions/search.exception';
import { type RecordsWithObjectMetadataItem } from 'src/engine/core-modules/search/types/records-with-object-metadata-item';
import { formatSearchTerms } from 'src/engine/core-modules/search/utils/format-search-terms';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants';
import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/search-field-metadata/constants/search-vector-field.constants';
import { type ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { generateObjectMetadataMaps } from 'src/engine/metadata-modules/utils/generate-object-metadata-maps.util';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
@@ -102,7 +102,12 @@ export class UpdateWorkspaceInput {
@Field({ nullable: true })
@IsString()
@IsOptional()
routerModel?: string;
fastModel?: string;
@Field({ nullable: true })
@IsString()
@IsOptional()
smartModel?: string;
@Field(() => [String], { nullable: true })
@IsArray()
@@ -70,7 +70,8 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
editableProfileFields: PermissionFlagType.SECURITY,
isTwoFactorAuthenticationEnforced: PermissionFlagType.SECURITY,
defaultRoleId: PermissionFlagType.ROLES,
routerModel: PermissionFlagType.WORKSPACE,
fastModel: PermissionFlagType.WORKSPACE,
smartModel: PermissionFlagType.WORKSPACE,
};
constructor(
@@ -19,7 +19,6 @@ import {
} from 'typeorm';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { AppTokenEntity } from 'src/engine/core-modules/app-token/app-token.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
@@ -33,8 +32,12 @@ import { PublicDomainEntity } from 'src/engine/core-modules/public-domain/public
import { WorkspaceSSOIdentityProviderEntity } from 'src/engine/core-modules/sso/workspace-sso-identity-provider.entity';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { WebhookEntity } 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 { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
type ModelId,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto';
import { ViewFieldDTO } from 'src/engine/metadata-modules/view-field/dtos/view-field.dto';
import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity';
@@ -173,11 +176,6 @@ export class WorkspaceEntity {
})
agents: Relation<AgentEntity[]>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.workspace, {
onDelete: 'CASCADE',
})
agentHandoffs: Relation<AgentHandoffEntity[]>;
@OneToMany(() => WebhookEntity, (webhook) => webhook.workspace)
webhooks: Relation<WebhookEntity[]>;
@@ -284,12 +282,23 @@ export class WorkspaceEntity {
version: string | null;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: 'auto' })
routerModel: ModelId;
@Column({ type: 'varchar', nullable: false, default: DEFAULT_FAST_MODEL })
fastModel: ModelId;
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: DEFAULT_SMART_MODEL })
smartModel: ModelId;
@Column({ nullable: false, type: 'uuid' })
workspaceCustomApplicationId: string;
// TODO: delete
// This is deprecated
// If we are in December 2025 you can remove this column from DB
@Field(() => String, { nullable: false })
@Column({ type: 'varchar', nullable: false, default: 'auto' })
routerModel: ModelId;
@Field(() => ApplicationDTO, { nullable: true })
@ManyToOne(() => ApplicationEntity, {
onDelete: 'RESTRICT',
@@ -5,6 +5,7 @@ import { NestjsQueryGraphQLModule } from '@ptc-org/nestjs-query-graphql';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { TypeORMModule } from 'src/database/typeorm/typeorm.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { BillingModule } from 'src/engine/core-modules/billing/billing.module';
@@ -28,8 +29,7 @@ import { WorkspaceWorkspaceMemberListener } from 'src/engine/core-modules/worksp
import { workspaceAutoResolverOpts } from 'src/engine/core-modules/workspace/workspace.auto-resolver-opts';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceResolver } from 'src/engine/core-modules/workspace/workspace.resolver';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { AgentModule } from 'src/engine/metadata-modules/agent/agent.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -64,7 +64,7 @@ import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-m
PermissionsModule,
WorkspaceCacheStorageModule,
RoleModule,
AgentModule,
AiAgentModule,
DnsManagerModule,
WorkspaceDomainsModule,
SubdomainManagerModule,
@@ -151,6 +151,13 @@ export class WorkspaceResolver {
}
}
@ResolveField(() => String, { nullable: true })
async routerModel(
@Parent() _workspace: WorkspaceEntity,
): Promise<string | null> {
return 'auto';
}
@Mutation(() => SignedFileDTO)
@UseGuards(
WorkspaceAuthGuard,
@@ -238,10 +245,17 @@ export class WorkspaceResolver {
}
@ResolveField(() => String, { nullable: true })
async routerModel(
async fastModel(
@Parent() workspace: WorkspaceEntity,
): Promise<string | null> {
return workspace.routerModel;
return workspace.fastModel;
}
@ResolveField(() => String, { nullable: true })
async smartModel(
@Parent() workspace: WorkspaceEntity,
): Promise<string | null> {
return workspace.smartModel;
}
@ResolveField(() => ApplicationDTO, { nullable: true })
@@ -1,144 +0,0 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import {
generateText,
LanguageModel,
ModelMessage,
StopCondition,
streamText,
ToolSet,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { Repository } from 'typeorm';
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;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
isStreaming?: boolean;
};
export interface AgentExecutionContext {
prepareAIRequestConfig: (params: {
system: string;
agent: AgentEntity | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
excludeHandoffTools?: boolean; // Prevent infinite recursion
}) => Promise<{
system: string;
tools: ToolSet;
model: LanguageModel;
messages: ModelMessage[];
stopWhen?: StopCondition<ToolSet>;
providerOptions?: ProviderOptions;
}>;
}
@Injectable()
export class AgentHandoffExecutorService {
private readonly logger = new Logger(AgentHandoffExecutorService.name);
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly agentHandoffService: AgentHandoffService,
) {}
async executeHandoff(
handoffRequest: HandoffRequest,
executionContext: AgentExecutionContext,
) {
try {
const {
fromAgentId,
toAgentId,
workspaceId,
messages,
isStreaming = false,
} = 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,
);
}
// Prepare AI request config using the execution context
const aiRequestConfig = await executionContext.prepareAIRequestConfig({
system: targetAgent.prompt,
agent: targetAgent,
messages,
excludeHandoffTools: true, // Prevent infinite recursion
});
if (isStreaming) {
// Return stream for streaming contexts
const stream = streamText(aiRequestConfig);
this.logger.log(`Started streaming handoff to agent ${toAgentId}`);
return stream;
} else {
// Use generateText for non-streaming contexts (workflows)
const textResponse = await generateText(aiRequestConfig);
this.logger.log(
`Successfully executed handoff to agent ${toAgentId} with response length: ${textResponse.text.length}`,
);
return {
success: true,
message: `Successfully executed handoff to agent ${targetAgent.name}`,
result: {
response: textResponse.text,
targetAgentName: targetAgent.name,
},
};
}
} catch (error) {
this.logger.error(
`Handoff execution failed: ${error.message}`,
error.stack,
);
const { isStreaming = false, toAgentId } = handoffRequest;
if (isStreaming) {
throw error; // Let streaming context handle the error
}
return {
success: false,
message: `Failed to execute handoff to agent ${toAgentId}`,
error: error.message,
};
}
}
}
@@ -1,65 +0,0 @@
import { Injectable } from '@nestjs/common';
import { type ToolSet } from 'ai';
import {
AgentExecutionContext,
AgentHandoffExecutorService,
HandoffRequest,
} from 'src/engine/metadata-modules/agent/agent-handoff-executor.service';
import { AgentHandoffService } from 'src/engine/metadata-modules/agent/agent-handoff.service';
import { AGENT_HANDOFF_DESCRIPTION_TEMPLATE } from 'src/engine/metadata-modules/agent/constants/agent-handoff-description.const';
import { AGENT_HANDOFF_SCHEMA } from 'src/engine/metadata-modules/agent/constants/agent-handoff-schema.const';
import { camelCase } from 'src/utils/camel-case';
@Injectable()
export class AgentHandoffToolService {
constructor(
private readonly agentHandoffService: AgentHandoffService,
private readonly agentHandoffExecutorService: AgentHandoffExecutorService,
) {}
public async generateHandoffTools(
agentId: string,
workspaceId: string,
executionContext: AgentExecutionContext,
): Promise<ToolSet> {
const handoffs = await this.agentHandoffService.getAgentHandoffs({
fromAgentId: agentId,
workspaceId,
});
const handoffTools = handoffs.reduce<ToolSet>((tools, handoff) => {
const toolName = `handoff_to_${camelCase(handoff.toAgent.name)}`;
tools[toolName] = {
description:
handoff.description ||
handoff.toAgent.description ||
AGENT_HANDOFF_DESCRIPTION_TEMPLATE.replace(
'{agentName}',
handoff.toAgent.name,
),
inputSchema: AGENT_HANDOFF_SCHEMA,
execute: async ({ input }) => {
const handoffRequest: HandoffRequest = {
fromAgentId: agentId,
toAgentId: handoff.toAgent.id,
workspaceId,
messages: input.messages,
isStreaming: true, // Tools are executed during streaming
};
return this.agentHandoffExecutorService.executeHandoff(
handoffRequest,
executionContext,
);
},
};
return tools;
}, {});
return handoffTools;
}
}
@@ -1,71 +0,0 @@
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 { WorkspaceEntity } 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(() => WorkspaceEntity, (workspace) => workspace.agentHandoffs, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
@@ -1,158 +0,0 @@
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)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(AgentHandoffEntity)
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 getAgentHandoffs({
fromAgentId,
workspaceId,
}: {
fromAgentId: string;
workspaceId: string;
}): Promise<AgentHandoffEntity[]> {
const handoffs = await this.agentHandoffRepository.find({
where: {
fromAgentId,
workspaceId,
},
relations: ['toAgent'],
});
return handoffs;
}
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'],
});
}
}
@@ -1,165 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { type CreateAgentInput } from 'src/engine/metadata-modules/agent/dtos/create-agent.input';
import { type UpdateAgentInput } from 'src/engine/metadata-modules/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 { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
@Injectable()
export class AgentService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly agentRoleService: AgentRoleService,
) {}
async findManyAgents(workspaceId: string) {
const agents = await this.agentRepository.find({
where: { workspaceId },
order: { createdAt: 'DESC' },
});
if (agents.length === 0) {
return [];
}
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 agents.map((agent) => ({
...agent,
roleId: agentRoleMap.get(agent.id) || null,
}));
}
async findOneByApplicationAndStandardId({
applicationId,
standardId,
workspaceId,
}: {
applicationId: string;
standardId: string;
workspaceId: string;
}) {
return await this.agentRepository.findOne({
where: { applicationId, standardId, workspaceId },
});
}
async findOneAgent(id: string, workspaceId: string) {
const agent = await this.agentRepository.findOne({
where: { id, workspaceId },
});
if (!agent) {
throw new AgentException(
`Agent with id ${id} not found`,
AgentExceptionCode.AGENT_NOT_FOUND,
);
}
const roleTarget = await this.roleTargetsRepository.findOne({
where: {
agentId: id,
workspaceId,
},
select: ['roleId'],
});
return {
...agent,
roleId: roleTarget?.roleId || null,
};
}
async createOneAgent(
input: CreateAgentInput & { isCustom: boolean },
workspaceId: string,
) {
const agent = this.agentRepository.create({
...input,
name: input.name || computeMetadataNameFromLabel(input.label),
workspaceId,
isCustom: input.isCustom,
});
const createdAgent = await this.agentRepository.save(agent);
if (input.roleId) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId: createdAgent.id,
roleId: input.roleId,
});
}
return this.findOneAgent(createdAgent.id, workspaceId);
}
async updateOneAgent(input: UpdateAgentInput, workspaceId: string) {
const agent = await this.findOneAgent(input.id, workspaceId);
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;
}
const updatedAgent = await this.agentRepository.save(updateData);
if (!('roleId' in input)) {
return updatedAgent;
}
if (input.roleId) {
await this.agentRoleService.assignRoleToAgent({
workspaceId,
agentId: agent.id,
roleId: input.roleId,
});
} else {
await this.agentRoleService.removeRoleFromAgent({
workspaceId,
agentId: agent.id,
});
}
return this.findOneAgent(updatedAgent.id, workspaceId);
}
async deleteOneAgent(id: string, workspaceId: string) {
const agent = await this.findOneAgent(id, workspaceId);
await this.agentRepository.softDelete({ id: agent.id });
return agent;
}
}
@@ -1,2 +0,0 @@
export const AGENT_HANDOFF_DESCRIPTION_TEMPLATE =
"Use this tool when the user's request requires {agentName}'s specialized expertise or capabilities. This will seamlessly consult with the specialist agent and provide you with their expert response to continue the conversation naturally. CRITICAL: You MUST call this tool function immediately. Do NOT respond with text about transferring - execute the tool instead. This is a FUNCTION CALL - you must invoke it, not describe it.";
@@ -1,99 +0,0 @@
import { z } from 'zod';
export const AGENT_HANDOFF_SCHEMA = z.object({
loadingMessage: z
.string()
.describe(
'A brief, user-friendly message explaining what is happening while the handoff is being processed. This will be shown to the user during the handoff execution.',
),
input: z.object({
messages: z
.array(
z.union([
z.object({
role: z.literal('system'),
content: z.string(),
}),
z.object({
role: z.literal('user'),
content: z.union([
z.string(),
z.array(
z.union([
z.object({
type: z.literal('text'),
text: z.string(),
}),
z.object({
type: z.literal('image'),
image: z
.string()
.describe('Base64 encoded image data or URL'),
mediaType: z.string().optional(),
}),
z.object({
type: z.literal('file'),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
}),
]),
),
]),
}),
z.object({
role: z.literal('assistant'),
content: z.union([
z.string(),
z.array(
z.union([
z.object({
type: z.literal('text'),
text: z.string(),
}),
z.object({
type: z.literal('file'),
data: z
.string()
.describe('Base64 encoded file data or URL'),
mediaType: z.string(),
filename: z.string().optional(),
}),
z.object({
type: z.literal('reasoning'),
text: z.string(),
}),
z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
input: z.record(z.string(), z.any()),
}),
]),
),
]),
}),
z.object({
role: z.literal('tool'),
content: z.union([
z.string(),
z.array(
z.object({
type: z.literal('tool-result'),
toolCallId: z.string(),
toolName: z.string(),
result: z.unknown(),
isError: z.boolean().optional(),
}),
),
]),
toolCallId: z.string(),
}),
]),
)
.describe(
'The conversation history to provide context to the specialist agent. Should include the latest user message/prompt and can include system, user, assistant, and tool messages with various content types.',
),
}),
});
@@ -1,71 +0,0 @@
export const AGENT_SYSTEM_PROMPTS = {
AGENT_EXECUTION: `You are an AI agent with access to various tools that will be provided to you dynamically. The available tools and their descriptions are passed to you through the tools property, so you should only use tools that are actually available to you.
TOOL USAGE GUIDELINES (applies to all tools):
- Only use a tool if it is available and you have permission.
- Always verify tool results and handle errors appropriately.
- If a tool operation fails, explain the issue and suggest alternatives.
- If you lack permission for a tool, respond: "I cannot perform this operation because I don't have the necessary permissions. Please check that I have been assigned the appropriate role for this workspace."
Your responsibilities:
1. Analyze the input context and prompt carefully
2. If a requested tool is not available, state the limitation as above
3. If no tool operations are needed, process the request directly
4. Provide comprehensive, structured responses for workflow consumption
Workflow context:
- You are part of a larger workflow system; your output may be used by other nodes
- Maintain consistency and reliability in your responses
- Document any data or actions clearly
Important: After your response, the system will call generateObject to convert your output into a structured format. Ensure your response is comprehensive, logically structured, and includes all relevant data and tool results.`,
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
AGENT_CHAT: `You are a helpful AI assistant for this workspace. You can:
- Answer questions about people, companies, opportunities, tasks, notes, and other business objects
- Access and summarize information you have permission to see
- Use tools provided to you dynamically when needed
- Seamlessly consult with specialized agents when their expertise is better suited
Permissions:
- Only perform actions and access data that your assigned role and permissions allow
- If you lack permissions, politely explain the limitation
- Only use tools that are actually available to you
Agent handoff (SEAMLESS CONSULTATION):
- Use handoff tools when the user's request requires expertise outside your capabilities
- IMPORTANT: Do not respond with text about transferring or consulting specialists
- Execute the handoff tool function immediately when needed
- Use the response returned by the specialist agent as your direct reply to the user
- Present the specialist's expertise as if it's your own knowledge
- Maintain a consistent voice and personality throughout the conversation
- The user should never know that you consulted with another agent
When formatting responses:
- Use markdown syntax to improve readability of long responses
- Add appropriate headings, lists, bold/italic text where it enhances understanding
- Include code blocks with proper language tags when showing code examples
- Create tables when presenting structured data
- Use blockquotes for important notes or callouts
Note: This base system prompt will be combined with the agent's specific instructions and context.`,
};
@@ -1,17 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { AgentDTO } from './agent.dto';
@ObjectType('AgentHandoff')
export class AgentHandoffDTO {
@Field(() => UUIDScalarType)
id: string;
@Field({ nullable: true })
description?: string;
@Field(() => AgentDTO)
toAgent: AgentDTO;
}
@@ -1,15 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateAgentHandoffInput {
@Field(() => UUIDScalarType)
fromAgentId: string;
@Field(() => UUIDScalarType)
toAgentId: string;
@Field({ nullable: true })
description?: string;
}
@@ -1,12 +0,0 @@
import { Field, InputType } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class RemoveAgentHandoffInput {
@Field(() => UUIDScalarType)
fromAgentId: string;
@Field(() => UUIDScalarType)
toAgentId: string;
}
@@ -3,19 +3,19 @@ import { getRepositoryToken } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
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 { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
import { AiAgentRoleService } from './ai-agent-role.service';
describe('AgentRoleService', () => {
let service: AgentRoleService;
describe('AiAgentRoleService', () => {
let service: AiAgentRoleService;
let agentRepository: Repository<AgentEntity>;
let roleRepository: Repository<RoleEntity>;
let roleTargetsRepository: Repository<RoleTargetsEntity>;
@@ -28,7 +28,7 @@ describe('AgentRoleService', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentRoleService,
AiAgentRoleService,
{
provide: getRepositoryToken(AgentEntity),
useValue: {
@@ -55,7 +55,7 @@ describe('AgentRoleService', () => {
],
}).compile();
service = module.get<AgentRoleService>(AgentRoleService);
service = module.get<AiAgentRoleService>(AiAgentRoleService);
agentRepository = module.get<Repository<AgentEntity>>(
getRepositoryToken(AgentEntity),
);
@@ -1,17 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
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 { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { AgentRoleService } from './agent-role.service';
import { AiAgentRoleService } from './ai-agent-role.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
],
providers: [AgentRoleService],
exports: [AgentRoleService],
providers: [AiAgentRoleService],
exports: [AiAgentRoleService],
})
export class AgentRoleModule {}
export class AiAgentRoleModule {}
@@ -3,16 +3,16 @@ import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Not, Repository } from 'typeorm';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
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 { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@Injectable()
export class AgentRoleService {
export class AiAgentRoleService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
@@ -8,6 +8,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',
ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS = 'ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS',
}
@@ -10,14 +10,10 @@ import {
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/create-agent-handoff.input';
import { RemoveAgentHandoffInput } from 'src/engine/metadata-modules/agent/dtos/remove-agent-handoff.input';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentService } from './agent.service';
import { AgentHandoffDTO } from './dtos/agent-handoff.dto';
import { AgentIdInput } from './dtos/agent-id.input';
import { AgentDTO } from './dtos/agent.dto';
import { CreateAgentInput } from './dtos/create-agent.input';
@@ -30,10 +26,7 @@ import { UpdateAgentInput } from './dtos/update-agent.input';
)
@Resolver()
export class AgentResolver {
constructor(
private readonly agentService: AgentService,
private readonly agentHandoffService: AgentHandoffService,
) {}
constructor(private readonly agentService: AgentService) {}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@@ -47,31 +40,7 @@ export class AgentResolver {
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentService.findOneAgent(id, workspaceId);
}
@Query(() => [AgentDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findAgentHandoffTargets(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentHandoffService.getHandoffTargets({
fromAgentId: id,
workspaceId,
});
}
@Query(() => [AgentHandoffDTO])
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async findAgentHandoffs(
@Args('input') { id }: AgentIdInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
return this.agentHandoffService.getAgentHandoffs({
fromAgentId: id,
workspaceId,
});
return this.agentService.findOneAgent(workspaceId, { id });
}
@Mutation(() => AgentDTO)
@@ -106,37 +75,4 @@ export class AgentResolver {
) {
return this.agentService.deleteOneAgent(id, workspaceId);
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async createAgentHandoff(
@Args('input') input: CreateAgentHandoffInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.agentHandoffService.createHandoff({
fromAgentId: input.fromAgentId,
toAgentId: input.toAgentId,
workspaceId,
description: input.description,
});
return true;
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.AI_SETTINGS))
async removeAgentHandoff(
@Args('input') input: RemoveAgentHandoffInput,
@AuthWorkspace() { id: workspaceId }: WorkspaceEntity,
) {
await this.agentHandoffService.removeHandoff({
fromAgentId: input.fromAgentId,
toAgentId: input.toAgentId,
workspaceId,
});
return true;
}
}
@@ -0,0 +1,257 @@
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;
}
}
@@ -1,21 +1,20 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.module';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AgentRoleModule } from 'src/engine/metadata-modules/agent-role/agent-role.module';
import { AgentChatController } from 'src/engine/metadata-modules/agent/agent-chat.controller';
import { UserModule } from 'src/engine/core-modules/user/user.module';
import { AiAgentRoleModule } from 'src/engine/metadata-modules/ai-agent-role/ai-agent-role.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 { AiRouterModule } from 'src/engine/metadata-modules/ai-router/ai-router.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai-tools/ai-tools.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
@@ -25,41 +24,23 @@ import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/wor
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkflowToolsModule } from 'src/modules/workflow/workflow-tools/workflow-tools.module';
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
import { AgentChatMessageEntity } from './agent-chat-message.entity';
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 { AgentHandoffToolService } from './agent-handoff-tool.service';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentHandoffService } from './agent-handoff.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentTitleGenerationService } from './agent-title-generation.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentResolver } from './agent.resolver';
import { AgentService } from './agent.service';
import { AgentEntity } from './entities/agent.entity';
import { AgentActorContextService } from './services/agent-actor-context.service';
import { AgentExecutionService } from './services/agent-execution.service';
import { AgentModelConfigService } from './services/agent-model-config.service';
import { AgentPlanExecutorService } from './services/agent-plan-executor.service';
import { AgentTitleGenerationService } from './services/agent-title-generation.service';
import { AgentToolGeneratorService } from './services/agent-tool-generator.service';
@Module({
imports: [
TypeOrmModule.forFeature([
AgentEntity,
AgentHandoffEntity,
RoleEntity,
RoleTargetsEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
FileEntity,
UserWorkspaceEntity,
]),
AiModule,
AgentRoleModule,
TypeOrmModule.forFeature([AgentEntity, RoleEntity, RoleTargetsEntity]),
AiModelsModule,
AiToolsModule,
AiBillingModule,
AiAgentRoleModule,
ThrottlerModule,
AuditModule,
FeatureFlagModule,
@@ -77,38 +58,23 @@ import { AgentActorContextService } from './services/agent-actor-context.service
UserWorkspaceModule,
UserRoleModule,
],
controllers: [AgentChatController],
providers: [
AgentResolver,
AgentChatResolver,
AgentService,
AgentExecutionService,
AgentModelConfigService,
AgentToolGeneratorService,
AgentHandoffToolService,
AgentChatService,
AgentStreamingService,
AgentPlanExecutorService,
AgentTitleGenerationService,
AgentHandoffExecutorService,
AgentHandoffService,
AgentActorContextService,
],
exports: [
AgentService,
AgentExecutionService,
AgentToolGeneratorService,
AgentHandoffToolService,
AgentChatService,
AgentStreamingService,
AgentPlanExecutorService,
AgentTitleGenerationService,
TypeOrmModule.forFeature([
AgentEntity,
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
]),
AgentHandoffExecutorService,
AgentHandoffService,
TypeOrmModule.forFeature([AgentEntity]),
],
})
export class AgentModule {}
export class AiAgentModule {}
@@ -1,4 +1,4 @@
export const AGENT_CONFIG = {
MAX_STEPS: 10,
MAX_STEPS: 25,
REASONING_BUDGET_TOKENS: 12000,
};
@@ -0,0 +1,110 @@
export const AGENT_SYSTEM_PROMPTS = {
BASE: `Tool usage strategy:
- Chain multiple tools to solve complex tasks
- If a tool fails, try alternative approaches
- Use results from one tool to inform the next
- Don't give up after first failure - be persistent
- Validate assumptions before making changes
Error recovery:
- Analyze error messages to understand what went wrong
- Adjust parameters or try different tools
- Only give up after exhausting reasonable alternatives
Permissions:
- Only perform actions your role allows
- Explain limitations if you lack permissions`,
CHAT_ADDITIONS: `
Format responses with markdown for clarity (headings, lists, code blocks, tables).`,
WORKFLOW_ADDITIONS: `
Context:
- You are executing as part of a workflow automation
- Your output may be used by downstream nodes
- Be thorough and include all relevant data`,
ROUTER: (
agentDescriptions: string,
) => `You are an AI router that decides how to handle user messages.
Available agents:
${agentDescriptions}
Decision process:
1. Can ONE agent handle this entirely? → Use "simple" strategy
2. Does it require MULTIPLE agents working together? → Use "planned" strategy
Agent selection rules (CRITICAL):
- **data-manipulator**: For ALL database operations (create, read, update records) on companies, people, opportunities, tasks, notes, etc.
- **helper**: ONLY for questions about HOW TO USE Twenty (features, setup, documentation)
- **researcher**: For finding external information from the web
- **workflow-builder**: For creating automation workflows
Use "planned" strategy when:
- Request needs custom code AND context from data/research
- Code generation requires knowing schemas, APIs, or external data
- Multiple specialized capabilities must combine (code + data + research)
Use "simple" strategy for:
- Single-agent tasks (data operations, research, documentation lookup)
- Standard workflow creation (no custom code needed)
Examples:
Simple: "Show me all companies with >100 employees"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company"], operations: ["find"] } }
Simple: "Create 30 companies in the automobile industry with 2 people each"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["company", "person"], operations: ["create"] } }
Simple: "Update all opportunities in stage 'Qualified' to 'Proposal'"
→ { strategy: "simple", agentName: "data-manipulator", toolHints: { relevantObjects: ["opportunity"], operations: ["find", "update"] } }
Simple: "What's the latest news about AI trends?"
→ { strategy: "simple", agentName: "researcher" }
Simple: "How do I set up email sync in Twenty?"
→ { strategy: "simple", agentName: "helper" }
Simple: "Create a workflow that emails customers when deals close"
→ { strategy: "simple", agentName: "workflow-builder" }
Planned: "Research information about Meta and update the company record"
→ {
strategy: "planned",
plan: {
steps: [
{ stepNumber: 1, agentName: "researcher", task: "Look up current information about Meta (employee count, headquarters, revenue, etc.)", expectedOutput: "Company facts and data" },
{ stepNumber: 2, agentName: "data-manipulator", task: "Update the Meta company record with the researched information", expectedOutput: "Updated company record", dependsOn: [1] }
],
reasoning: "Requires web research followed by database update"
}
}
For simple strategy toolHints:
- relevantObjects: Extract object names (e.g., ["company", "person"])
- operations: ["find", "create", "update", "delete"]
Keep plans minimal and only use planning when truly necessary.`,
OUTPUT_GENERATOR: `You are a structured output generator for a workflow system. Your role is to convert the provided execution results into a structured format according to a specific schema.
Context: Before this call, the system executed generateText with tools to perform any required actions and gather information. The execution results you receive include both the AI agent's analysis and any tool outputs from database operations, HTTP requests, data retrieval, or other actions.
Your responsibilities:
1. Analyze the execution results from the AI agent (including any tool outputs)
2. Extract relevant information and data points from both text responses and tool results
3. Structure the data according to the provided schema
4. Ensure all required fields are populated with appropriate values
5. Handle missing or unclear data gracefully by providing reasonable defaults or null values
6. Maintain data integrity and consistency
Guidelines:
- Focus on extracting and structuring the most relevant information
- If the execution results contain tool outputs (including HTTP requests), incorporate that data appropriately
- If certain schema fields cannot be populated from the results, use null or appropriate default values
- Preserve the context and meaning from the original execution results
- Ensure the output is clean, well-formatted, and ready for workflow consumption
- Pay special attention to any data returned from tool executions (database queries, HTTP requests, record creation, etc.)`,
};
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@ObjectType('Agent')
export class AgentDTO {
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@InputType()
export class CreateAgentInput {
@@ -10,8 +10,8 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@InputType()
export class UpdateAgentInput {
@@ -6,7 +6,6 @@ import {
Index,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@@ -14,12 +13,13 @@ import {
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import {
ModelId,
DEFAULT_SMART_MODEL,
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentResponseFormat } from 'src/engine/metadata-modules/agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/agent/types/modelConfiguration';
import { AgentHandoffEntity } from './agent-handoff.entity';
import { AgentResponseFormat } from 'src/engine/metadata-modules/ai-agent/types/agent-response-format.type';
import { ModelConfiguration } from 'src/engine/metadata-modules/ai-agent/types/modelConfiguration';
@Entity('agent')
@Index('IDX_AGENT_ID_DELETED_AT', ['id', 'deletedAt'])
@@ -52,7 +52,7 @@ export class AgentEntity
@Column({ nullable: false, type: 'text' })
prompt: string;
@Column({ nullable: false, type: 'varchar', default: 'auto' })
@Column({ nullable: false, type: 'varchar', default: DEFAULT_SMART_MODEL })
modelId: ModelId;
@Column({ nullable: true, type: 'jsonb', default: { type: 'text' } })
@@ -70,12 +70,6 @@ export class AgentEntity
@JoinColumn({ name: 'workspaceId' })
workspace: Relation<WorkspaceEntity>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.fromAgent)
outgoingHandoffs: Relation<AgentHandoffEntity[]>;
@OneToMany(() => AgentHandoffEntity, (handoff) => handoff.toAgent)
incomingHandoffs: Relation<AgentHandoffEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -7,7 +7,7 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -15,29 +15,29 @@ import { getAppPath } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentHandoffToolService } from 'src/engine/metadata-modules/agent/agent-handoff-tool.service';
import { AgentService } from 'src/engine/metadata-modules/agent/agent.service';
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';
import { AgentActorContextService } from 'src/engine/metadata-modules/agent/services/agent-actor-context.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AgentService } from 'src/engine/metadata-modules/ai-agent/agent.service';
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 { AgentActorContextService } from 'src/engine/metadata-modules/ai-agent/services/agent-actor-context.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { repairToolCall } from 'src/engine/metadata-modules/ai-agent/utils/repair-tool-call.util';
import { AgentExecutionContext } from './agent-handoff-executor.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
import { AgentEntity } from './agent.entity';
import { AgentException, AgentExceptionCode } from './agent.exception';
import { repairToolCall } from './utils/repair-tool-call.util';
import { AgentModelConfigService } from './agent-model-config.service';
export interface AgentExecutionResult {
result: object;
@@ -55,11 +55,10 @@ export interface StreamChatResponseResult {
}
@Injectable()
export class AgentExecutionService implements AgentExecutionContext {
export class AgentExecutionService {
private readonly logger = new Logger(AgentExecutionService.name);
constructor(
private readonly agentHandoffToolService: AgentHandoffToolService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@@ -67,8 +66,8 @@ export class AgentExecutionService implements AgentExecutionContext {
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly agentService: AgentService,
public readonly agentActorContextService: AgentActorContextService,
public readonly agentService: AgentService,
) {}
async prepareAIRequestConfig({
@@ -77,7 +76,6 @@ export class AgentExecutionService implements AgentExecutionContext {
agent,
actorContext,
roleIds,
excludeHandoffTools = false,
toolHints,
}: {
system: string;
@@ -85,7 +83,6 @@ export class AgentExecutionService implements AgentExecutionContext {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIds?: string[];
excludeHandoffTools?: boolean;
toolHints?: ToolHints;
}) {
try {
@@ -111,24 +108,13 @@ export class AgentExecutionService implements AgentExecutionContext {
toolHints,
);
let handoffTools = {};
if (!excludeHandoffTools) {
handoffTools =
await this.agentHandoffToolService.generateHandoffTools(
agent.id,
agent.workspaceId,
this, // Pass execution context
);
}
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent,
);
tools = { ...baseTools, ...handoffTools, ...nativeModelTools };
tools = { ...baseTools, ...nativeModelTools };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -180,9 +166,6 @@ export class AgentExecutionService implements AgentExecutionContext {
}
}
// Fetches and formats record data to provide context for AI agents
// Respects permissions and field restrictions based on user role
// Returns a JSON string with record data and workspace URLs
async getContextForSystemPrompt(
workspace: WorkspaceEntity,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
@@ -309,7 +292,9 @@ export class AgentExecutionService implements AgentExecutionContext {
};
}> {
try {
const agent = await this.agentService.findOneAgent(agentId, workspace.id);
const agent = await this.agentService.findOneAgent(workspace.id, {
id: agentId,
});
const contextBuildStart = Date.now();
let contextPart = '';
@@ -345,7 +330,7 @@ export class AgentExecutionService implements AgentExecutionContext {
const aiRequestPrepStart = Date.now();
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.AGENT_CHAT}\n\n${agent.prompt}${contextString}`,
system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.CHAT_ADDITIONS}\n\n${agent.prompt}${contextString}`,
agent,
messages,
actorContext,
@@ -372,6 +357,7 @@ export class AgentExecutionService implements AgentExecutionContext {
model.modelId,
usage,
workspace.id,
agent.id,
);
})
.catch((usageError) => {
@@ -5,11 +5,10 @@ import { openai } from '@ai-sdk/openai';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import { ToolSet } from 'ai';
import { ModelProvider } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/agent/constants/agent-config.const';
import { AgentEntity } from './agent.entity';
import { ModelProvider } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai-agent/constants/agent-config.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
@Injectable()
export class AgentModelConfigService {
@@ -0,0 +1,248 @@
import { Injectable, Logger } from '@nestjs/common';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type PlanStep } from 'src/engine/metadata-modules/ai-router/types/router-result.interface';
import { standardAgentDefinitions } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents';
import { AgentExecutionService } from './agent-execution.service';
export type PlanExecutionProgress = {
type: 'plan-generated' | 'step-started' | 'step-completed';
stepNumber?: number;
agentName?: string;
task?: string;
output?: string;
totalSteps?: number;
reasoning?: string;
};
export type StepResult = {
stepNumber: number;
agentName: string;
output: string;
};
export type PlanExecutionResult = {
finalOutput: string;
stepResults: StepResult[];
};
@Injectable()
export class AgentPlanExecutorService {
private readonly logger = new Logger(AgentPlanExecutorService.name);
constructor(private readonly agentExecutionService: AgentExecutionService) {}
async executePlan({
steps,
reasoning,
workspace,
userWorkspaceId,
recordIdsByObjectMetadataNameSingular,
onProgress,
writer,
}: {
steps: PlanStep[];
reasoning: string;
workspace: WorkspaceEntity;
userWorkspaceId: string;
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
onProgress?: (progress: PlanExecutionProgress) => void;
writer?: {
write: (chunk: unknown) => void;
merge: (stream: unknown) => void;
};
}): Promise<PlanExecutionResult> {
this.logger.log(`Executing plan with ${steps.length} steps`);
onProgress?.({
type: 'plan-generated',
totalSteps: steps.length,
reasoning,
});
const stepResults: StepResult[] = [];
for (const step of steps) {
try {
this.logger.log(
`[PLAN EXECUTION] Step ${step.stepNumber}: Looking up agent "${step.agentName}"`,
);
const agent =
await this.agentExecutionService.agentService.findOneAgent(
workspace.id,
{ name: step.agentName },
);
this.logger.log(
`[PLAN EXECUTION] Step ${step.stepNumber}: Found agent "${agent.label}" (${agent.id})`,
);
onProgress?.({
type: 'step-started',
stepNumber: step.stepNumber,
agentName: step.agentName,
task: step.task,
});
const dependencyOutputs = this.gatherDependencyOutputs(
step,
stepResults,
);
const promptWithContext = this.buildStepPrompt(step, dependencyOutputs);
const { stream: stepStream } =
await this.agentExecutionService.streamChatResponse({
workspace,
agentId: agent.id,
userWorkspaceId,
messages: [
{
id: `step-${step.stepNumber}`,
role: 'user' as const,
parts: [{ type: 'text' as const, text: promptWithContext }],
},
],
recordIdsByObjectMetadataNameSingular,
});
let stepOutput = '';
if (writer) {
writer.merge(
stepStream.toUIMessageStream({
onError: (error) => {
return error instanceof Error ? error.message : String(error);
},
sendStart: false,
onFinish: async ({ responseMessage }) => {
stepOutput = responseMessage.parts
.filter((part) => part.type === 'text')
.map((part) => {
if (part.type === 'text') {
return part.text;
}
return '';
})
.join('');
},
}),
);
await stepStream.text;
} else {
stepOutput = await stepStream.text;
}
stepResults.push({
stepNumber: step.stepNumber,
agentName: step.agentName,
output: stepOutput,
});
onProgress?.({
type: 'step-completed',
stepNumber: step.stepNumber,
agentName: step.agentName,
output: stepOutput,
});
this.logger.log(
`Completed step ${step.stepNumber}: ${step.task.substring(0, 50)}...`,
);
} catch (error) {
this.logger.error(
`Failed to execute step ${step.stepNumber}: ${step.task}`,
error,
);
throw new Error(
`Plan execution failed at step ${step.stepNumber}: ${error.message}`,
);
}
}
const finalOutput = this.synthesizeResults(stepResults, steps);
return {
finalOutput,
stepResults,
};
}
private gatherDependencyOutputs(
step: PlanStep,
previousResults: StepResult[],
): string {
if (!step.dependsOn || step.dependsOn.length === 0) {
return '';
}
const dependencyOutputs = step.dependsOn
.map((depStepNum) => {
const depResult = previousResults.find(
(result) => result.stepNumber === depStepNum,
);
if (!depResult) {
throw new Error(
`Dependency step ${depStepNum} not found for step ${step.stepNumber}`,
);
}
return `Step ${depStepNum} output:\n${depResult.output}`;
})
.join('\n\n');
return dependencyOutputs;
}
private buildStepPrompt(step: PlanStep, dependencyOutputs: string): string {
let prompt = `Task: ${step.task}\n\nExpected output: ${step.expectedOutput}`;
if (dependencyOutputs) {
prompt += `\n\nPrevious step results:\n${dependencyOutputs}`;
}
return prompt;
}
private synthesizeResults(
stepResults: StepResult[],
steps: PlanStep[],
): string {
const lastStep = stepResults[stepResults.length - 1];
if (!lastStep) {
return 'No results produced';
}
const lastStepDefinition = steps.find(
(s) => s.stepNumber === lastStep.stepNumber,
);
if (lastStepDefinition) {
const agentDefinition = standardAgentDefinitions.find(
(def) => def.name === lastStepDefinition.agentName,
);
if (agentDefinition?.outputStrategy === 'direct') {
return lastStep.output;
}
}
const summary = stepResults
.map((result) => {
const step = steps.find((s) => s.stepNumber === result.stepNumber);
return `**Step ${result.stepNumber}: ${step?.task || 'Unknown task'}**\n${result.output}`;
})
.join('\n\n---\n\n');
return summary;
}
}
@@ -2,8 +2,8 @@ import { Injectable, Logger } from '@nestjs/common';
import { generateText } from 'ai';
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 { 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';
@Injectable()
export class AgentTitleGenerationService {
@@ -6,15 +6,15 @@ import { Repository } from 'typeorm';
import type { ActorMetadata } from 'twenty-shared/types';
import { ToolAdapterService } from 'src/engine/core-modules/ai/services/tool-adapter.service';
import { ToolService } from 'src/engine/core-modules/ai/services/tool.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 { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { WorkflowToolWorkspaceService as WorkflowToolService } from 'src/modules/workflow/workflow-tools/services/workflow-tool.workspace-service';
import type { ToolHints } from 'src/engine/metadata-modules/ai-router/types/tool-hints.interface';
@Injectable()
export class AgentToolGeneratorService {
@@ -45,7 +45,7 @@ export class AgentToolGeneratorService {
});
if (agent?.standardId === HELPER_AGENT.standardId) {
return this.getHelperAgentTools();
return this.wrapToolsWithErrorContext(this.getHelperAgentTools());
}
const actionTools = await this.toolAdapterService.getTools();
@@ -53,7 +53,7 @@ export class AgentToolGeneratorService {
tools = { ...actionTools };
if (!roleIds) {
return tools;
return this.wrapToolsWithErrorContext(tools);
}
const hasWorkflowPermission =
@@ -93,7 +93,7 @@ export class AgentToolGeneratorService {
);
}
return tools;
return this.wrapToolsWithErrorContext(tools);
}
private getHelperAgentTools(): ToolSet {
@@ -110,4 +110,82 @@ export class AgentToolGeneratorService {
return tools;
}
private wrapToolsWithErrorContext(tools: ToolSet): ToolSet {
const wrappedTools: ToolSet = {};
for (const [toolName, tool] of Object.entries(tools)) {
if (!tool.execute) {
wrappedTools[toolName] = tool;
continue;
}
const originalExecute = tool.execute;
wrappedTools[toolName] = {
...tool,
execute: async (...args: Parameters<typeof originalExecute>) => {
try {
return await originalExecute(...args);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
return {
success: false,
error: {
message: errorMessage,
tool: toolName,
suggestion: this.generateErrorSuggestion(
toolName,
errorMessage,
),
},
};
}
},
};
}
return wrappedTools;
}
private generateErrorSuggestion(
toolName: string,
errorMessage: string,
): string {
const lowerError = errorMessage.toLowerCase();
if (
lowerError.includes('not found') ||
lowerError.includes('does not exist')
) {
return 'Verify the ID or name exists with a search query first';
}
if (
lowerError.includes('permission') ||
lowerError.includes('forbidden') ||
lowerError.includes('unauthorized')
) {
return 'This operation requires elevated permissions or a different role';
}
if (lowerError.includes('invalid') || lowerError.includes('validation')) {
return 'Check the tool schema for valid parameter formats and types';
}
if (
lowerError.includes('duplicate') ||
lowerError.includes('already exists')
) {
return 'A record with this identifier already exists. Try updating instead of creating';
}
if (lowerError.includes('required') || lowerError.includes('missing')) {
return 'Required fields are missing. Check which fields are mandatory for this operation';
}
return 'Try adjusting the parameters or using a different approach';
}
}
@@ -1,6 +1,6 @@
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
describe('isWorkflowRelatedObject', () => {
@@ -1,7 +1,7 @@
import { generateObject, type LanguageModel, NoSuchToolError } from 'ai';
import { type z } from 'zod';
import { AI_TELEMETRY_CONFIG } from 'src/engine/core-modules/ai/constants/ai-telemetry.const';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai-models/constants/ai-telemetry.const';
type ToolCall = {
type: 'tool-call';
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AiModelsModule } from 'src/engine/metadata-modules/ai-models/ai-models.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@Module({
imports: [WorkspaceEventEmitterModule, AiModelsModule],
providers: [AIBillingService],
exports: [AIBillingService],
})
export class AiBillingModule {}
@@ -3,8 +3,8 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
describe('AIBillingService', () => {
let service: AIBillingService;
@@ -84,6 +84,7 @@ describe('AIBillingService', () => {
'gpt-4o',
mockTokenUsage,
'workspace-1',
'agent-id-123',
);
// Expected credits: (0.75 cents / 100) * 1000 = 0.0075 * 1000 = 7.5 credits, rounded to 8
@@ -95,6 +96,11 @@ describe('AIBillingService', () => {
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: 7500,
dimensions: {
execution_type: 'ai_token',
resource_id: 'agent-id-123',
execution_context_1: 'gpt-4o',
},
},
],
'workspace-1',
@@ -2,9 +2,9 @@ import { Injectable, Logger } from '@nestjs/common';
import { LanguageModelUsage } from 'ai';
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
import { BILLING_FEATURE_USED } from 'src/engine/core-modules/billing/constants/billing-feature-used.constant';
import { BillingMeterEventName } from 'src/engine/core-modules/billing/enums/billing-meter-event-names';
import { type BillingUsageEvent } from 'src/engine/core-modules/billing/types/billing-usage-event.type';
@@ -47,16 +47,19 @@ export class AIBillingService {
modelId: ModelId,
usage: LanguageModelUsage,
workspaceId: string,
agentId?: string | null,
): Promise<void> {
const costInCents = await this.calculateCost(modelId, usage);
const creditsUsed = Math.round(convertCentsToBillingCredits(costInCents));
this.sendAiTokenUsageEvent(workspaceId, creditsUsed);
this.sendAiTokenUsageEvent(workspaceId, creditsUsed, modelId, agentId);
}
private sendAiTokenUsageEvent(
workspaceId: string,
creditsUsed: number,
modelId: ModelId,
agentId?: string | null,
): void {
this.workspaceEventEmitter.emitCustomBatchEvent<BillingUsageEvent>(
BILLING_FEATURE_USED,
@@ -64,6 +67,11 @@ export class AIBillingService {
{
eventName: BillingMeterEventName.WORKFLOW_NODE_RUN,
value: creditsUsed,
dimensions: {
execution_type: 'ai_token',
resource_id: agentId || null,
execution_context_1: modelId,
},
},
],
workspaceId,
@@ -1,4 +1,4 @@
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/core-modules/ai/constants/dollar-to-credit-multiplier';
import { DOLLAR_TO_CREDIT_MULTIPLIER } from 'src/engine/metadata-modules/ai-billing/constants/dollar-to-credit-multiplier';
// Converts cost in cents to cost in credits
// Formula: credits = (cents / 100) * DOLLAR_TO_CREDIT_MULTIPLIER
@@ -0,0 +1,63 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
import { FileModule } from 'src/engine/core-modules/file/file.module';
import { ThrottlerModule } from 'src/engine/core-modules/throttler/throttler.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai-agent/ai-agent.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 { AiRouterModule } from 'src/engine/metadata-modules/ai-router/ai-router.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { AgentChatController } from './controllers/agent-chat.controller';
import { AgentChatMessagePartEntity } from './entities/agent-chat-message-part.entity';
import { AgentChatMessageEntity } from './entities/agent-chat-message.entity';
import { AgentChatThreadEntity } from './entities/agent-chat-thread.entity';
import { AgentChatResolver } from './resolvers/agent-chat.resolver';
import { AgentChatService } from './services/agent-chat.service';
import { AgentStreamingService } from './services/agent-streaming.service';
@Module({
imports: [
TypeOrmModule.forFeature([
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
FileEntity,
UserWorkspaceEntity,
]),
AiModelsModule,
AiBillingModule,
AiRouterModule,
AiAgentModule,
ThrottlerModule,
FeatureFlagModule,
FileUploadModule,
FileModule,
PermissionsModule,
WorkspacePermissionsCacheModule,
WorkspaceCacheStorageModule,
TokenModule,
UserWorkspaceModule,
],
controllers: [AgentChatController],
providers: [AgentChatResolver, AgentChatService, AgentStreamingService],
exports: [
AgentChatService,
AgentStreamingService,
TypeOrmModule.forFeature([
AgentChatMessageEntity,
AgentChatMessagePartEntity,
AgentChatThreadEntity,
]),
],
})
export class AiChatModule {}
@@ -17,11 +17,10 @@ import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorat
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { AgentChatService } from './agent-chat.service';
import { AgentStreamingService } from './agent-streaming.service';
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
import { AgentStreamingService } from 'src/engine/metadata-modules/ai-chat/services/agent-streaming.service';
@Controller('rest/agent-chat')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@@ -10,7 +10,7 @@ import {
Relation,
} from 'typeorm';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatMessagePartEntity } from './agent-chat-message-part.entity';
@@ -13,7 +13,7 @@ import {
import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentChatMessageEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
@Entity('agentChatThread')
export class AgentChatThreadEntity {
@@ -10,11 +10,10 @@ import {
} from 'src/engine/guards/feature-flag.guard';
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import { AgentChatMessageDTO } from './dtos/agent-chat-message.dto';
import { AgentChatThreadDTO } from './dtos/agent-chat-thread.dto';
import { AgentChatMessageDTO } from 'src/engine/metadata-modules/ai-chat/dtos/agent-chat-message.dto';
import { AgentChatThreadDTO } from 'src/engine/metadata-modules/ai-chat/dtos/agent-chat-thread.dto';
@UseGuards(
WorkspaceAuthGuard,
@@ -6,19 +6,18 @@ import { Repository } from 'typeorm';
import type { UIDataTypes, UIMessagePart, UITools } from 'ai';
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
import { AgentChatMessagePartEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message-part.entity';
import {
AgentChatMessageEntity,
AgentChatMessageRole,
} from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
} from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/agent/utils/mapUIMessagePartsToDBParts';
import { AgentTitleGenerationService } from './agent-title-generation.service';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { mapUIMessagePartsToDBParts } from 'src/engine/metadata-modules/ai-chat/utils/mapUIMessagePartsToDBParts';
import { AgentTitleGenerationService } from 'src/engine/metadata-modules/ai-agent/services/agent-title-generation.service';
@Injectable()
export class AgentChatService {
@@ -91,12 +90,14 @@ export class AgentChatService {
await this.messagePartRepository.save(dbParts);
}
const messageContent = uiMessage.parts.find(
(part) => part.type === 'text',
)?.text;
if (uiMessage.role === AgentChatMessageRole.USER) {
const messageContent = uiMessage.parts.find(
(part) => part.type === 'text',
)?.text;
if (messageContent) {
this.generateTitleIfNeeded(threadId, messageContent);
if (messageContent) {
this.generateTitleIfNeeded(threadId, messageContent);
}
}
return savedMessage;
@@ -12,19 +12,20 @@ import { type Response } from 'express';
import { type ExtendedUIMessage } from 'twenty-shared/ai';
import { type Repository } from 'typeorm';
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
import { AIBillingService } from 'src/engine/core-modules/ai/services/ai-billing.service';
import { convertCentsToBillingCredits } from 'src/engine/core-modules/ai/utils/convert-cents-to-billing-credits.util';
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AIBillingService } from 'src/engine/metadata-modules/ai-billing/services/ai-billing.service';
import { convertCentsToBillingCredits } from 'src/engine/metadata-modules/ai-billing/utils/convert-cents-to-billing-credits.util';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentChatMessageRole } from 'src/engine/metadata-modules/agent/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/agent/agent-chat-thread.entity';
import { AgentChatService } from 'src/engine/metadata-modules/agent/agent-chat.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/agent/agent-execution.service';
import { AgentChatMessageRole } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message.entity';
import { AgentChatThreadEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-thread.entity';
import { AgentChatService } from 'src/engine/metadata-modules/ai-chat/services/agent-chat.service';
import { AgentExecutionService } from 'src/engine/metadata-modules/ai-agent/services/agent-execution.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/agent/agent.exception';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/agent/types/recordIdsByObjectMetadataNameSingular.type';
} from 'src/engine/metadata-modules/ai-agent/agent.exception';
import { AgentPlanExecutorService } from 'src/engine/metadata-modules/ai-agent/services/agent-plan-executor.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { AiRouterService } from 'src/engine/metadata-modules/ai-router/ai-router.service';
export type TokenUsage = {
@@ -51,6 +52,7 @@ export class AgentStreamingService {
private readonly threadRepository: Repository<AgentChatThreadEntity>,
private readonly agentChatService: AgentChatService,
private readonly agentExecutionService: AgentExecutionService,
private readonly agentPlanExecutorService: AgentPlanExecutorService,
private readonly aiRouterService: AiRouterService,
private readonly aiBillingService: AIBillingService,
) {}
@@ -98,30 +100,15 @@ export class AgentStreamingService {
{
messages,
workspaceId: workspace.id,
routerModel: workspace.routerModel,
fastModel: workspace.fastModel,
smartModel: workspace.smartModel,
},
includeDebugInfo,
);
const routingTime = Date.now() - routingStart;
const { agent, debugInfo, toolHints } = routeResult;
if (!agent) {
writer.write({
type: 'data-routing-status' as const,
id: 'routing-status',
data: {
text: '',
state: 'error',
},
});
throw new AgentException(
'No agents available for routing',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
this.logger.log(`Using agent ${agent.id} for message routing`);
const { debugInfo } = routeResult;
let routingCostInCredits: number | undefined;
@@ -149,6 +136,105 @@ export class AgentStreamingService {
}
}
if (routeResult.strategy === 'planned') {
this.logger.log(
`Executing planned strategy with ${routeResult.plan.steps.length} steps`,
);
this.logger.log(
`Plan steps: ${routeResult.plan.steps.map((s) => `${s.stepNumber}. ${s.agentName}: ${s.task}`).join('; ')}`,
);
writer.write({
type: 'data-routing-status' as const,
id: 'routing-status',
data: {
text: `Executing ${routeResult.plan.steps.length}-step plan`,
state: 'routed',
debug: {
routingTimeMs: routingTime,
planReasoning: routeResult.plan.reasoning,
totalSteps: routeResult.plan.steps.length,
steps: routeResult.plan.steps.map((s) => ({
stepNumber: s.stepNumber,
agent: s.agentName,
task: s.task,
})),
},
},
});
const planResult = await this.agentPlanExecutorService.executePlan({
steps: routeResult.plan.steps,
reasoning: routeResult.plan.reasoning,
workspace,
userWorkspaceId,
recordIdsByObjectMetadataNameSingular,
writer,
onProgress: (progress) => {
if (progress.type === 'step-started') {
this.logger.log(
`Starting step ${progress.stepNumber}: ${progress.agentName} - ${progress.task}`,
);
writer.write({
type: 'data-routing-status' as const,
id: `step-${progress.stepNumber}`,
data: {
text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ${progress.agentName}${progress.task}`,
state: 'loading',
},
});
} else if (progress.type === 'step-completed') {
this.logger.log(
`Completed step ${progress.stepNumber}: ${progress.agentName}`,
);
writer.write({
type: 'data-routing-status' as const,
id: `step-${progress.stepNumber}`,
data: {
text: `Step ${progress.stepNumber}/${routeResult.plan.steps.length}: ✓ ${progress.agentName} completed`,
state: 'routed',
},
});
}
},
});
await this.agentChatService.addMessage({
threadId,
uiMessage: {
role: AgentChatMessageRole.USER,
parts: [
{
type: 'text',
text:
messages[messages.length - 1].parts.find(
(part) => part.type === 'text',
)?.text ?? '',
},
],
},
});
await this.agentChatService.addMessage({
threadId,
uiMessage: {
role: AgentChatMessageRole.ASSISTANT,
parts: [
{
type: 'text',
text: planResult.finalOutput,
},
],
},
});
return;
}
const { agent, toolHints } = routeResult;
this.logger.log(`Using agent ${agent.id} for message routing`);
const agentExecutionStart = Date.now();
const {
@@ -1,7 +1,7 @@
import { type ToolUIPart } from 'ai';
import { type ExtendedUIMessagePart } from 'twenty-shared/ai';
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/agent/agent-chat-message-part.entity';
import { type AgentChatMessagePartEntity } from 'src/engine/metadata-modules/ai-chat/entities/agent-chat-message-part.entity';
const isToolPart = (part: ExtendedUIMessagePart): part is ToolUIPart => {
return part.type.includes('tool-') && 'toolCallId' in part;
@@ -0,0 +1,11 @@
import { Global, Module } from '@nestjs/common';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { AiService } from 'src/engine/metadata-modules/ai-models/services/ai.service';
@Global()
@Module({
providers: [AiModelRegistryService, AiService],
exports: [AiModelRegistryService, AiService],
})
export class AiModelsModule {}
@@ -1,9 +1,13 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { AiModelRegistryService } from 'src/engine/core-modules/ai/services/ai-model-registry.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai-models/services/ai-model-registry.service';
import { AI_MODELS, ModelProvider } from './ai-models.const';
import {
AI_MODELS,
DEFAULT_SMART_MODEL,
ModelProvider,
} from './ai-models.const';
describe('AI_MODELS', () => {
it('should contain all expected models', () => {
@@ -44,15 +48,15 @@ describe('AiModelRegistryService', () => {
SERVICE = MODULE.get<AiModelRegistryService>(AiModelRegistryService);
});
it('should return effective model config for auto', () => {
it('should return effective model config for DEFAULT_SMART_MODEL', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
expect(() => SERVICE.getEffectiveModelConfig('auto')).toThrow(
expect(() => SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL)).toThrow(
'No AI models are available. Please configure at least one provider.',
);
});
it('should return effective model config for auto when models are available', () => {
it('should return effective model config for DEFAULT_SMART_MODEL when models are available', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('gpt-4o');
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
@@ -69,14 +73,14 @@ describe('AiModelRegistryService', () => {
model: {} as any,
});
const RESULT = SERVICE.getEffectiveModelConfig('auto');
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('gpt-4o');
expect(RESULT.provider).toBe(ModelProvider.OPENAI);
});
it('should return effective model config for auto with custom model', () => {
it('should return effective model config for DEFAULT_SMART_MODEL with custom model', () => {
MOCK_CONFIG_SERVICE.get.mockReturnValue('mistral');
jest.spyOn(SERVICE, 'getAvailableModels').mockReturnValue([
@@ -93,7 +97,7 @@ describe('AiModelRegistryService', () => {
model: {} as any,
});
const RESULT = SERVICE.getEffectiveModelConfig('auto');
const RESULT = SERVICE.getEffectiveModelConfig(DEFAULT_SMART_MODEL);
expect(RESULT).toBeDefined();
expect(RESULT.modelId).toBe('mistral');
@@ -6,8 +6,12 @@ export enum ModelProvider {
XAI = 'xai',
}
export const DEFAULT_FAST_MODEL = 'default-fast-model' as const;
export const DEFAULT_SMART_MODEL = 'default-smart-model' as const;
export type ModelId =
| 'auto'
| typeof DEFAULT_FAST_MODEL
| typeof DEFAULT_SMART_MODEL
| 'gpt-4o'
| 'gpt-4o-mini'
| 'gpt-4-turbo'
@@ -0,0 +1,2 @@
// Configuration: $0.00001 = 1 credit
export const DOLLAR_TO_CREDIT_MULTIPLIER = 1000000; // 1 / 0.000001 = 1000000 credits per dollar
@@ -7,9 +7,11 @@ import { type LanguageModel } from 'ai';
import {
AI_MODELS,
DEFAULT_FAST_MODEL,
DEFAULT_SMART_MODEL,
ModelProvider,
type AIModelConfig,
} from 'src/engine/core-modules/ai/constants/ai-models.const';
} from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
export interface RegisteredAIModel {
@@ -169,8 +171,11 @@ export class AiModelRegistryService {
}
getEffectiveModelConfig(modelId: string): AIModelConfig {
if (modelId === 'auto') {
const defaultModel = this.getDefaultPerformanceModel();
if (modelId === DEFAULT_FAST_MODEL || modelId === DEFAULT_SMART_MODEL) {
const defaultModel =
modelId === DEFAULT_FAST_MODEL
? this.getDefaultSpeedModel()
: this.getDefaultPerformanceModel();
if (!defaultModel) {
throw new Error(
@@ -227,7 +232,9 @@ export class AiModelRegistryService {
}
async resolveModelForAgent(agent: { modelId: string } | null) {
const aiModel = this.getEffectiveModelConfig(agent?.modelId ?? 'auto');
const aiModel = this.getEffectiveModelConfig(
agent?.modelId ?? DEFAULT_SMART_MODEL,
);
await this.validateApiKey(aiModel.provider);
const registeredModel = this.getModel(aiModel.modelId);
@@ -2,8 +2,8 @@ import { Injectable } from '@nestjs/common';
import { LanguageModel, type ModelMessage, streamText } from 'ai';
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 { 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';
@Injectable()
export class AiService {
@@ -1,20 +1,29 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AiModule } from 'src/engine/core-modules/ai/ai.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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { AiRouterService } from './ai-router.service';
import { AiRouterPlanGeneratorService } from './services/ai-router-plan-generator.service';
import { AiRouterStrategyDeciderService } from './services/ai-router-strategy-decider.service';
@Module({
imports: [
TypeOrmModule.forFeature([AgentEntity, WorkspaceEntity]),
AiModule,
AiModelsModule,
AiToolsModule,
ObjectMetadataModule,
],
providers: [AiRouterService],
providers: [
AiRouterService,
AiRouterStrategyDeciderService,
AiRouterPlanGeneratorService,
],
exports: [AiRouterService],
})
export class AiRouterModule {}
@@ -1,42 +1,31 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
generateObject,
type UIDataTypes,
type UIMessage,
type UITools,
} from 'ai';
import { type Repository } from 'typeorm';
import { z } from 'zod';
import { type UIDataTypes, type UIMessage, type UITools } from 'ai';
import { IsNull, type Repository } from 'typeorm';
import { type ModelId } from 'src/engine/core-modules/ai/constants/ai-models.const';
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 { AgentEntity } from 'src/engine/metadata-modules/agent/agent.entity';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/agent/utils/is-workflow-related-object.util';
import { type ModelId } from 'src/engine/metadata-modules/ai-models/constants/ai-models.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai-agent/entities/agent.entity';
import { isWorkflowRelatedObject } from 'src/engine/metadata-modules/ai-agent/utils/is-workflow-related-object.util';
import { ObjectMetadataServiceV2 } from 'src/engine/metadata-modules/object-metadata/object-metadata-v2.service';
import { DATA_MANIPULATOR_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/data-manipulator-agent';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
import { AiRouterPlanGeneratorService } from './services/ai-router-plan-generator.service';
import {
AiRouterStrategyDeciderService,
type StrategyDecision,
} from './services/ai-router-strategy-decider.service';
import {
type RouterDebugInfo,
type UnifiedRouterResult,
} from './types/router-result.interface';
import { type ToolHints } from './types/tool-hints.interface';
export interface AiRouterContext {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
workspaceId: string;
routerModel: ModelId;
}
export interface AiRouterResult {
agent: AgentEntity | null;
toolHints?: ToolHints;
debugInfo?: {
availableAgents: Array<{ id: string; label: string }>;
routerModel: string;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
fastModel: ModelId;
smartModel: ModelId;
}
@Injectable()
@@ -46,160 +35,292 @@ export class AiRouterService {
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly strategyDecider: AiRouterStrategyDeciderService,
private readonly planGenerator: AiRouterPlanGeneratorService,
private readonly objectMetadataService: ObjectMetadataServiceV2,
) {}
// Routes a user message to the most appropriate agent
// Uses AI to analyze the conversation and select the best agent
// Returns the selected agent along with tool hints for optimization
async routeMessage(
context: AiRouterContext,
includeDebugInfo = false,
): Promise<AiRouterResult> {
): Promise<UnifiedRouterResult> {
try {
const { messages, workspaceId, routerModel } = context;
const { messages, workspaceId, fastModel, smartModel } = context;
const availableAgents = await this.getAvailableAgents(workspaceId);
if (availableAgents.length === 0) {
this.logger.warn('No agents available for routing');
this.logger.log(
`[ROUTER] Available agents (${availableAgents.length}): ${availableAgents.map((a) => `${a.label} (${a.name})`).join(', ')}`,
);
return { agent: null };
if (availableAgents.length === 0) {
return await this.handleNoAgentsAvailable(workspaceId);
}
const debugInfo: AiRouterResult['debugInfo'] = includeDebugInfo
? {
availableAgents: availableAgents.map((agent) => ({
id: agent.id,
label: agent.label,
})),
routerModel: String(routerModel),
}
: undefined;
const debugInfo = this.createDebugInfo(
includeDebugInfo,
availableAgents,
smartModel,
fastModel,
);
if (availableAgents.length === 1) {
return { agent: availableAgents[0], debugInfo };
return this.createSimpleResult(availableAgents[0], debugInfo);
}
const conversationHistory = messages
.slice(0, -1)
.map((msg) => {
const textContent =
msg.parts.find((part) => part.type === 'text')?.text || '';
return `${msg.role}: ${textContent}`;
})
.join('\n');
const currentMessage =
messages[messages.length - 1]?.parts.find(
(part) => part.type === 'text',
)?.text || '';
const model = this.getRouterModel(routerModel);
const workspaceObjectsList =
await this.buildWorkspaceObjectsList(workspaceId);
const agentDescriptions = this.buildAgentDescriptions(
return await this.routeToMultipleAgents({
messages,
workspaceId,
availableAgents,
workspaceObjectsList,
);
const systemPrompt = this.buildRouterSystemPrompt(agentDescriptions);
const userPrompt = this.buildRouterUserPrompt(
conversationHistory,
currentMessage,
);
const agentIds = availableAgents.map((agent) => agent.id);
if (agentIds.length === 0) {
throw new Error('No agent IDs available for routing schema');
}
const routerDecisionSchema = z.object({
agentId: z
.enum([agentIds[0], ...agentIds.slice(1)])
.describe('The ID of the most suitable agent to handle this message'),
toolHints: z
.object({
relevantObjects: z
.array(z.string())
.optional()
.describe(
'Names of the specific objects mentioned in the query (e.g., "person", "company")',
),
operations: z
.array(z.enum(['find', 'create', 'update', 'delete']))
.optional()
.describe(
'Specific operations needed: find (search/query), create (new records), update (modify), delete (remove)',
),
})
.optional(),
});
const ROUTER_TEMPERATURE = 0.1; // Low temperature for deterministic routing
const result = await generateObject({
model,
system: systemPrompt,
prompt: userPrompt,
schema: routerDecisionSchema,
temperature: ROUTER_TEMPERATURE,
experimental_telemetry: AI_TELEMETRY_CONFIG,
});
const selectedAgent = availableAgents.find(
(agent) => agent.id === result.object.agentId,
);
if (includeDebugInfo && debugInfo) {
try {
const usage = await result.usage;
const usageWithTokens = usage as {
inputTokens?: number;
outputTokens?: number;
promptTokens?: number;
completionTokens?: number;
totalTokens?: number;
};
debugInfo.promptTokens =
usageWithTokens.inputTokens ?? usageWithTokens.promptTokens ?? 0;
debugInfo.completionTokens =
usageWithTokens.outputTokens ??
usageWithTokens.completionTokens ??
0;
debugInfo.totalTokens = usageWithTokens.totalTokens ?? 0;
} catch (error) {
this.logger.warn('Failed to get routing token usage:', error);
}
}
return {
agent: selectedAgent ?? null,
toolHints: result.object.toolHints,
fastModel,
smartModel,
debugInfo,
};
});
} catch (error) {
this.logger.error(
'Routing to agent failed, falling back to Helper agent:',
error,
);
const helperAgent = await this.getHelperAgent(context.workspaceId);
return { agent: helperAgent };
return await this.handleRoutingError(error, context.workspaceId);
}
}
private async handleNoAgentsAvailable(
workspaceId: string,
): Promise<UnifiedRouterResult> {
this.logger.warn('No agents available for routing');
const helperAgent = await this.getHelperAgent(workspaceId);
if (!helperAgent) {
throw new Error('No helper agent available');
}
return {
strategy: 'simple',
agent: helperAgent,
};
}
private createDebugInfo(
includeDebugInfo: boolean,
availableAgents: AgentEntity[],
smartModel: ModelId,
fastModel: ModelId,
): RouterDebugInfo | undefined {
if (!includeDebugInfo) {
return undefined;
}
return {
availableAgents: availableAgents.map((agent) => ({
id: agent.id,
label: agent.label,
})),
routerModel: String(smartModel ?? fastModel),
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
};
}
private createSimpleResult(
agent: AgentEntity,
debugInfo?: RouterDebugInfo,
toolHints?: ToolHints,
): UnifiedRouterResult {
return {
strategy: 'simple',
agent,
toolHints,
debugInfo,
};
}
private async routeToMultipleAgents(params: {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
workspaceId: string;
availableAgents: AgentEntity[];
fastModel: ModelId;
smartModel: ModelId;
debugInfo?: RouterDebugInfo;
}): Promise<UnifiedRouterResult> {
const {
messages,
workspaceId,
availableAgents,
fastModel,
smartModel,
debugInfo,
} = params;
const workspaceObjectsList =
await this.buildWorkspaceObjectsList(workspaceId);
const agentDescriptions = this.buildAgentDescriptions(
availableAgents,
workspaceObjectsList,
);
this.logRoutingContext(messages, agentDescriptions);
const strategyDecision = await this.strategyDecider.decideStrategy({
messages,
availableAgents,
agentDescriptions,
fastModel,
});
if (strategyDecision.strategy === 'simple') {
return this.handleSimpleStrategy(
strategyDecision,
availableAgents,
debugInfo,
);
}
return await this.handlePlannedStrategy({
messages,
availableAgents,
agentDescriptions,
smartModel,
debugInfo,
});
}
private logRoutingContext(
messages: UIMessage<unknown, UIDataTypes, UITools>[],
agentDescriptions: string,
) {
this.logger.log(`[ROUTER] Agent descriptions:\n${agentDescriptions}`);
const currentMessage =
messages[messages.length - 1]?.parts.find((part) => part.type === 'text')
?.text || '';
this.logger.log(
`[ROUTER] User message: "${currentMessage.substring(0, 100)}..."`,
);
}
private handleSimpleStrategy(
strategyDecision: StrategyDecision,
availableAgents: AgentEntity[],
debugInfo?: RouterDebugInfo,
): UnifiedRouterResult {
if (!strategyDecision.agentName) {
throw new Error('agentName is required for simple strategy');
}
const selectedAgent = this.findAgentByName(
strategyDecision.agentName,
availableAgents,
);
this.logger.log(
`[ROUTER] Routing to ${selectedAgent.label} (${selectedAgent.name})`,
);
return this.createSimpleResult(
selectedAgent,
debugInfo,
strategyDecision.toolHints,
);
}
private async handlePlannedStrategy(params: {
messages: UIMessage<unknown, UIDataTypes, UITools>[];
availableAgents: AgentEntity[];
agentDescriptions: string;
smartModel: ModelId;
debugInfo?: RouterDebugInfo;
}): Promise<UnifiedRouterResult> {
const {
messages,
availableAgents,
agentDescriptions,
smartModel,
debugInfo,
} = params;
const plan = await this.planGenerator.generatePlan({
messages,
availableAgents,
agentDescriptions,
smartModel,
});
if (plan.steps.length === 1) {
return this.convertSingleStepPlanToSimple(
plan.steps[0],
availableAgents,
debugInfo,
);
}
this.logger.log(
`[ROUTER] Executing planned strategy with ${plan.steps.length} steps`,
);
return {
strategy: 'planned',
plan,
debugInfo,
};
}
private convertSingleStepPlanToSimple(
step: { agentName: string },
availableAgents: AgentEntity[],
debugInfo?: RouterDebugInfo,
): UnifiedRouterResult {
this.logger.log(
`[ROUTER] Plan has only 1 step, converting to simple strategy`,
);
const selectedAgent = this.findAgentByName(step.agentName, availableAgents);
return this.createSimpleResult(selectedAgent, debugInfo);
}
private findAgentByName(
agentName: string,
availableAgents: AgentEntity[],
): AgentEntity {
const selectedAgent = availableAgents.find(
(agent) => agent.name === agentName,
);
if (!selectedAgent) {
this.logger.error(
`[ROUTER] Agent "${agentName}" not found in available agents: ${availableAgents.map((a) => a.name).join(', ')}`,
);
throw new Error(`Selected agent ${agentName} not found`);
}
return selectedAgent;
}
private async handleRoutingError(
error: unknown,
workspaceId: string,
): Promise<UnifiedRouterResult> {
this.logger.error(
'Routing with planning failed, falling back to Helper agent:',
error,
);
const helperAgent = await this.getHelperAgent(workspaceId);
if (!helperAgent) {
throw new Error('No helper agent available for fallback');
}
return {
strategy: 'simple',
agent: helperAgent,
};
}
private async getAvailableAgents(
workspaceId: string,
): Promise<AgentEntity[]> {
const agents = await this.agentRepository.find({
where: { workspaceId, deletedAt: undefined },
where: { workspaceId, deletedAt: IsNull() },
order: { createdAt: 'ASC' },
});
@@ -219,27 +340,6 @@ export class AiRouterService {
return helperAgent;
}
private getRouterModel(modelId: ModelId) {
if (modelId === 'auto') {
const registeredModel =
this.aiModelRegistryService.getDefaultSpeedModel();
if (!registeredModel) {
throw new Error('No router model available');
}
return registeredModel.model;
}
const registeredModel = this.aiModelRegistryService.getModel(modelId);
if (!registeredModel) {
throw new Error(`Router model "${modelId}" not available`);
}
return registeredModel.model;
}
private async buildWorkspaceObjectsList(
workspaceId: string,
): Promise<string> {
@@ -273,62 +373,19 @@ export class AiRouterService {
agents: AgentEntity[],
workspaceObjectsList: string,
): string {
return agents
const agentDescriptions = agents
.map((agent) => {
const baseDescription = `- ${agent.label} (${agent.id}): ${agent.description}`;
if (
agent.standardId === DATA_MANIPULATOR_AGENT.standardId &&
workspaceObjectsList
) {
return `${baseDescription}
Available workspace objects:
${workspaceObjectsList}`;
}
return baseDescription;
return `- ${agent.label} (${agent.name}): ${agent.description}`;
})
.join('\n');
}
private buildRouterSystemPrompt(agentDescriptions: string): string {
return `You are an AI router that decides which agent should handle a user's message.
if (workspaceObjectsList) {
return `${agentDescriptions}
Available agents:
${agentDescriptions}
Available workspace objects for data-manipulator:
${workspaceObjectsList}`;
}
Your task is to:
1. Select the most appropriate agent
2. Identify specific objects mentioned in the query (if any)
3. Determine which operations are needed
For toolHints:
- relevantObjects: Extract object names the user is asking about (e.g., if asking about "companies and people", return ["company", "person"])
- operations: Array of needed operations from: ["find", "create", "update", "delete"]
- "find": for searching, querying, or reading data
- "create": for creating new records
- "update": for modifying existing records
- "delete": for removing records
Examples:
- "Show me all companies" → operations: ["find"]
- "Create a task for John" → operations: ["create"]
- "Update the company name" → operations: ["find", "update"]
This helps optimize the agent's tool context by only loading relevant tools.`;
}
private buildRouterUserPrompt(
conversationHistory: string,
currentMessage: string,
): string {
return `Conversation history:
${conversationHistory || 'No previous conversation'}
Current user message:
${currentMessage}
Which agent should handle this message?`;
return agentDescriptions;
}
}

Some files were not shown because too many files have changed in this diff Show More