refactor(twenty-server): consolidate AI tool provider architecture (#16355)

## Summary

Consolidates the AI tool provider architecture by creating a single
`ToolProviderService` as the entry point for all tool generation. This
removes multiple intermediate services and simplifies the codebase.

## Changes

### New Architecture
- **`ToolProviderService`**: Single service for all tool generation
with:
  - `getTools(spec)` - Get tools by category with permissions
  - `getToolByType(type)` - Get specific tool for workflow execution
  
- **`ToolCategory` enum**: Declarative specification of tool types:
  - `DATABASE_CRUD` - Record CRUD operations
  - `ACTION` - HTTP requests, email sending, article search
  - `WORKFLOW` - Workflow management tools
  - `METADATA` - Object/field metadata tools
  - `NATIVE_MODEL` - Model-specific tools (e.g., web search)

- **`ToolSpecification` type**: Clean API for requesting tools with
permissions

### Removed
- `AiToolsModule` - No longer needed
- `ToolService` - Logic inlined into ToolProviderService
- `ToolAdapterService` - Logic inlined into ToolProviderService
- `ToolRegistryService` - Logic inlined into ToolProviderService

### Updated
- All consumers (agents, chat, MCP, workflows) now use
`ToolProviderService`
- Test files updated accordingly

## Stats
- **547 insertions, 1146 deletions** (net ~600 lines removed)
- 4 services deleted
- 1 module deleted

## Testing
- [x] Typecheck passes
- [x] Lint passes
This commit is contained in:
Félix Malfait
2025-12-06 06:51:47 +01:00
committed by GitHub
parent 43ed4963d6
commit 223082a4da
33 changed files with 588 additions and 1169 deletions
@@ -1,13 +1,13 @@
import { Module } from '@nestjs/common';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
import { ToolProviderModule } from 'src/engine/core-modules/tool-provider/tool-provider.module';
import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module';
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AiBillingModule } from 'src/engine/metadata-modules/ai/ai-billing/ai-billing.module';
import { AiModelsModule } from 'src/engine/metadata-modules/ai/ai-models/ai-models.module';
import { AiToolsModule } from 'src/engine/metadata-modules/ai/ai-tools/ai-tools.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -19,7 +19,6 @@ import { AgentTurnEntity } from './entities/agent-turn.entity';
import { AgentActorContextService } from './services/agent-actor-context.service';
import { AgentAsyncExecutorService } from './services/agent-async-executor.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 { AgentToolGeneratorService } from './services/agent-tool-generator.service';
@@ -27,13 +26,13 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
imports: [
AiBillingModule,
AiModelsModule,
AiToolsModule,
AiAgentModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
UserRoleModule,
PermissionsModule,
WorkspaceCacheModule,
forwardRef(() => ToolProviderModule),
TypeOrmModule.forFeature([
AgentEntity,
AgentMessageEntity,
@@ -46,7 +45,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
AgentAsyncExecutorService,
AgentExecutionService,
AgentToolGeneratorService,
AgentModelConfigService,
AgentActorContextService,
AgentPlanExecutorService,
],
@@ -56,7 +54,6 @@ import { AgentToolGeneratorService } from './services/agent-tool-generator.servi
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentActorContextService,
AgentModelConfigService,
TypeOrmModule.forFeature([
AgentMessageEntity,
AgentMessagePartEntity,
@@ -11,6 +11,8 @@ import {
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
@@ -22,12 +24,9 @@ import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/ag
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AI_TELEMETRY_CONFIG } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-telemetry.const';
import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
// Agent execution within workflows uses database and action tools only.
// Workflow tools are intentionally excluded to avoid circular dependencies
@@ -39,8 +38,7 @@ export class AgentAsyncExecutorService {
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
private readonly toolProvider: ToolProviderService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
) {}
@@ -63,12 +61,11 @@ export class AgentAsyncExecutorService {
return [];
}
private async getToolsForWorkflowExecution(
private async getEffectiveRolePermissionConfig(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
rolePermissionConfig?: RolePermissionConfig,
): Promise<ToolSet> {
): Promise<RolePermissionConfig | undefined> {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId,
@@ -80,37 +77,15 @@ export class AgentAsyncExecutorService {
const agentRoleId = roleTarget?.roleId;
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
// Combine role IDs from config and agent
const allRoleIds = agentRoleId
? [...new Set([...configRoleIds, agentRoleId])]
: configRoleIds;
if (allRoleIds.length === 0) {
// No role context - return basic action tools only
return this.toolAdapterService.getTools();
return undefined;
}
const effectiveRoleContext: RolePermissionConfig = {
intersectionOf: allRoleIds,
};
// Get database CRUD tools
const databaseTools = await this.toolService.listTools(
effectiveRoleContext,
workspaceId,
actorContext,
);
// Get action tools (send email, http request, etc.)
const actionTools = await this.toolAdapterService.getTools(
effectiveRoleContext,
workspaceId,
);
return {
...databaseTools,
...actionTools,
};
return { intersectionOf: allRoleIds };
}
async executeAgent({
@@ -132,23 +107,28 @@ export class AgentAsyncExecutorService {
let providerOptions = {};
if (agent) {
tools = await this.getToolsForWorkflowExecution(
const effectiveRoleConfig = await this.getEffectiveRolePermissionConfig(
agent.id,
agent.workspaceId,
actorContext,
rolePermissionConfig,
);
// Add native model tools (web search, etc.) if configured
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent as unknown as Parameters<
typeof this.agentModelConfigService.getNativeModelTools
>[1],
);
tools = { ...tools, ...nativeModelTools };
// Workflow context: DATABASE_CRUD, ACTION, and NATIVE_MODEL tools only
// Workflow tools are excluded to prevent circular dependencies
tools = await this.toolProvider.getTools({
workspaceId: agent.workspaceId,
categories: [
ToolCategory.DATABASE_CRUD,
ToolCategory.ACTION,
ToolCategory.NATIVE_MODEL,
],
rolePermissionConfig: effectiveRoleConfig,
actorContext,
agent: agent as unknown as Parameters<
typeof this.toolProvider.getTools
>[0]['agent'],
wrapWithErrorContext: false,
});
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
@@ -32,9 +32,9 @@ import { AiModelRegistryService } from 'src/engine/metadata-modules/ai/ai-models
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { AgentModelConfigService } from 'src/engine/metadata-modules/ai/ai-models/services/agent-model-config.service';
import { AgentActorContextService } from './agent-actor-context.service';
import { AgentModelConfigService } from './agent-model-config.service';
import { AgentToolGeneratorService } from './agent-tool-generator.service';
// Re-export for backward compatibility
@@ -1,102 +0,0 @@
import { Injectable } from '@nestjs/common';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import { ProviderOptions } from '@ai-sdk/provider-utils';
import { ToolSet } from 'ai';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { ModelProvider } from 'src/engine/metadata-modules/ai/ai-models/constants/ai-models.const';
import { RegisteredAIModel } from 'src/engine/metadata-modules/ai/ai-models/services/ai-model-registry.service';
import { FlatAgentWithRoleId } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
@Injectable()
export class AgentModelConfigService {
constructor() {}
getProviderOptions(
model: RegisteredAIModel,
agent: FlatAgentWithRoleId,
): ProviderOptions {
switch (model.provider) {
case ModelProvider.XAI:
return this.getXaiProviderOptions(agent);
case ModelProvider.ANTHROPIC:
return this.getAnthropicProviderOptions(model);
default:
return {};
}
}
getNativeModelTools(
model: RegisteredAIModel,
agent: FlatAgentWithRoleId,
): ToolSet {
const tools: ToolSet = {};
if (!agent.modelConfiguration) {
return tools;
}
switch (model.provider) {
case ModelProvider.ANTHROPIC:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = anthropic.tools.webSearch_20250305();
}
break;
case ModelProvider.OPENAI:
if (agent.modelConfiguration.webSearch?.enabled) {
tools.web_search = openai.tools.webSearch();
}
break;
}
return tools;
}
private getXaiProviderOptions(agent: FlatAgentWithRoleId): ProviderOptions {
if (
!agent.modelConfiguration ||
(!agent.modelConfiguration.webSearch?.enabled &&
!agent.modelConfiguration.twitterSearch?.enabled)
) {
return {};
}
const sources: Array<{ type: string }> = [];
if (agent.modelConfiguration.webSearch?.enabled) {
sources.push({ type: 'web' });
}
if (agent.modelConfiguration.twitterSearch?.enabled) {
sources.push({ type: 'x' });
}
return {
xai: {
searchParameters: {
mode: 'auto',
...(sources.length > 0 && { sources }),
},
},
};
}
private getAnthropicProviderOptions(
model: RegisteredAIModel,
): ProviderOptions {
if (!model.doesSupportThinking) {
return {};
}
return {
anthropic: {
thinking: {
type: 'enabled',
budgetTokens: AGENT_CONFIG.REASONING_BUDGET_TOKENS,
},
},
};
}
}
@@ -1,30 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ToolSet } from 'ai';
import { Repository } from 'typeorm';
import type { ActorMetadata } from 'twenty-shared/types';
import { SearchArticlesTool } from 'src/engine/core-modules/tool/tools/search-articles-tool/search-articles-tool';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { ToolCategory } from 'src/engine/core-modules/tool-provider/enums/tool-category.enum';
import { ToolProviderService } from 'src/engine/core-modules/tool-provider/services/tool-provider.service';
import type { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
import { ToolAdapterService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool-adapter.service';
import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/tool.service';
import { HELPER_AGENT } from 'src/engine/workspace-manager/workspace-sync-metadata/standard-agents/agents/helper-agent';
@Injectable()
export class AgentToolGeneratorService {
private readonly logger = new Logger(AgentToolGeneratorService.name);
constructor(
@InjectRepository(AgentEntity)
private readonly agentRepository: Repository<AgentEntity>,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
private readonly searchArticlesTool: SearchArticlesTool,
) {}
constructor(private readonly toolProvider: ToolProviderService) {}
// Generates base tools for chat context (DATABASE_CRUD and ACTION)
// Additional tools (WORKFLOW, METADATA) are provided via additionalTools
// from ChatToolsProviderService to avoid circular dependencies
async generateToolsForAgent(
agentId: string,
workspaceId: string,
@@ -32,142 +24,24 @@ export class AgentToolGeneratorService {
roleIds?: string[],
toolHints?: ToolHints,
): Promise<ToolSet> {
let tools: ToolSet = {};
try {
const agent = await this.agentRepository.findOne({
where: { id: agentId },
});
if (agent?.standardId === HELPER_AGENT.standardId) {
return this.wrapToolsWithErrorContext(this.getHelperAgentTools());
}
const actionTools = await this.toolAdapterService.getTools();
tools = { ...actionTools };
if (!roleIds) {
return this.wrapToolsWithErrorContext(tools);
}
// Workflow tools are NOT generated here to avoid circular dependencies
// They are provided via additionalTools from ChatToolsProviderService in the chat context
const databaseTools = await this.toolService.listTools(
{ intersectionOf: roleIds },
return await this.toolProvider.getTools({
workspaceId,
categories: [ToolCategory.DATABASE_CRUD, ToolCategory.ACTION],
rolePermissionConfig: roleIds ? { intersectionOf: roleIds } : undefined,
actorContext,
toolHints,
);
tools = { ...tools, ...databaseTools };
const roleActionTools = await this.toolAdapterService.getTools(
{ intersectionOf: roleIds },
workspaceId,
);
tools = { ...tools, ...roleActionTools };
wrapWithErrorContext: true,
});
} catch (toolError) {
const errorMessage =
toolError instanceof Error ? toolError.message : 'Unknown error';
this.logger.warn(
`Failed to generate tools for agent ${agentId}: ${toolError.message}. Proceeding without tools.`,
`Failed to generate tools for agent ${agentId}: ${errorMessage}. Proceeding without tools.`,
);
return {};
}
return this.wrapToolsWithErrorContext(tools);
}
private getHelperAgentTools(): ToolSet {
const tools: ToolSet = {
search_articles: {
description: this.searchArticlesTool.description,
inputSchema: this.searchArticlesTool.inputSchema,
execute: async (params) =>
this.searchArticlesTool.execute(params.input),
},
};
this.logger.log('Generated search_articles tool for Helper agent');
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';
}
}