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:
+3
-6
@@ -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,
|
||||
|
||||
+25
-45
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
-102
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+17
-143
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user