refactor(workflow-tools): reorganize to one file per tool with co-located schemas (#16313)

## Summary

Reorganizes workflow tools to improve maintainability and
discoverability by having one file per tool with co-located input
schemas.

## Changes

- Create individual tool files in `tools/` directory (11 files)
- Co-locate input schemas with their tool implementations
- Add shared types file for dependencies and context
- Simplify workspace service to aggregate tool factories
- Remove centralized `schemas/` directory

## New Structure

```
workflow-tools/
├── services/
│   └── workflow-tool.workspace-service.ts
├── tools/
│   ├── activate-workflow-version.tool.ts
│   ├── compute-step-output-schema.tool.ts
│   ├── create-complete-workflow.tool.ts
│   ├── create-draft-from-workflow-version.tool.ts
│   ├── create-workflow-version-edge.tool.ts
│   ├── create-workflow-version-step.tool.ts
│   ├── deactivate-workflow-version.tool.ts
│   ├── delete-workflow-version-edge.tool.ts
│   ├── delete-workflow-version-step.tool.ts
│   ├── update-workflow-version-positions.tool.ts
│   └── update-workflow-version-step.tool.ts
├── types/
│   └── workflow-tool-dependencies.type.ts
└── workflow-tools.module.ts
```

## Benefits

- **Co-location**: Schema and tool logic are in the same file
- **Single responsibility**: Each file handles one tool
- **Easier maintenance**: Changes to a tool only touch one file
- **Better discoverability**: File names match tool names
This commit is contained in:
Félix Malfait
2025-12-04 21:06:49 +01:00
committed by GitHub
parent 1991ee850e
commit 9cecbaebc3
46 changed files with 2323 additions and 1186 deletions
@@ -1,31 +1,62 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.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';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { AgentMessagePartEntity } from './entities/agent-message-part.entity';
import { AgentMessageEntity } from './entities/agent-message.entity';
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';
@Module({
imports: [
AiBillingModule,
AiModelsModule,
AiToolsModule,
AiAgentModule,
WorkspaceDomainsModule,
UserWorkspaceModule,
UserRoleModule,
PermissionsModule,
WorkspaceCacheModule,
TypeOrmModule.forFeature([
AgentEntity,
AgentMessageEntity,
AgentMessagePartEntity,
AgentTurnEntity,
RoleTargetEntity,
]),
],
providers: [AgentAsyncExecutorService],
providers: [
AgentAsyncExecutorService,
AgentExecutionService,
AgentToolGeneratorService,
AgentModelConfigService,
AgentActorContextService,
AgentPlanExecutorService,
],
exports: [
AgentAsyncExecutorService,
AgentExecutionService,
AgentPlanExecutorService,
AgentToolGeneratorService,
AgentActorContextService,
AgentModelConfigService,
TypeOrmModule.forFeature([
AgentMessageEntity,
AgentMessagePartEntity,
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { type ActorMetadata } from 'twenty-shared/types';
import { buildCreatedByFromFullNameMetadata } from 'src/engine/core-modules/actor/utils/build-created-by-from-full-name-metadata.util';
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
import {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/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';
export type AgentActorContext = {
actorContext: ActorMetadata;
roleId: string;
};
@Injectable()
// eslint-disable-next-line @nx/workspace-inject-workspace-repository
export class AgentActorContextService {
constructor(
private readonly userWorkspaceService: UserWorkspaceService,
private readonly userRoleService: UserRoleService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async buildUserAndAgentActorContext(
userWorkspaceId: string,
workspaceId: string,
): Promise<AgentActorContext> {
const userWorkspace =
await this.userWorkspaceService.findById(userWorkspaceId);
if (!userWorkspace) {
throw new AgentException(
'User workspace not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: {
userId: userWorkspace.userId,
},
});
if (!workspaceMember) {
throw new AgentException(
'Workspace member not found for user',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const roleId = await this.userRoleService.getRoleIdForUserWorkspace({
userWorkspaceId,
workspaceId,
});
if (!roleId) {
throw new AgentException(
'User role not found',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
const actorContext = buildCreatedByFromFullNameMetadata({
fullNameMetadata: workspaceMember.name,
workspaceMemberId: workspaceMember.id,
});
return {
actorContext,
roleId,
};
}
}
@@ -11,6 +11,7 @@ import {
import { type ActorMetadata } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
import {
AgentException,
AgentExceptionCode,
@@ -18,7 +19,7 @@ import {
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent/services/agent-execution.service';
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';
@@ -26,18 +27,43 @@ import { ToolService } from 'src/engine/metadata-modules/ai/ai-tools/services/to
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';
// Agent execution within workflows uses database and action tools only.
// Workflow tools are intentionally excluded to avoid circular dependencies
// and recursive workflow execution.
@Injectable()
export class AgentAsyncExecutorService {
private readonly logger = new Logger(AgentAsyncExecutorService.name);
constructor(
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly toolAdapterService: ToolAdapterService,
private readonly toolService: ToolService,
@InjectRepository(RoleTargetEntity)
private readonly roleTargetRepository: Repository<RoleTargetEntity>,
private readonly toolService: ToolService,
) {}
private async getTools(
private extractRoleIds(
rolePermissionConfig?: RolePermissionConfig,
): string[] {
if (!rolePermissionConfig) {
return [];
}
if ('intersectionOf' in rolePermissionConfig) {
return rolePermissionConfig.intersectionOf;
}
if ('unionOf' in rolePermissionConfig) {
return rolePermissionConfig.unionOf;
}
return [];
}
private async getToolsForWorkflowExecution(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
@@ -45,43 +71,42 @@ export class AgentAsyncExecutorService {
): Promise<ToolSet> {
const roleTarget = await this.roleTargetRepository.findOne({
where: {
agentId: agentId,
agentId,
workspaceId,
},
select: ['roleId'],
});
const agentRoleId = roleTarget?.roleId;
const configRoleIds = this.extractRoleIds(rolePermissionConfig);
if (!rolePermissionConfig && !agentRoleId) {
return await this.toolAdapterService.getTools();
// 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();
}
let effectiveRoleContext: RolePermissionConfig;
if (
rolePermissionConfig &&
('intersectionOf' in rolePermissionConfig ||
'unionOf' in rolePermissionConfig)
) {
effectiveRoleContext = rolePermissionConfig;
} else if (agentRoleId) {
effectiveRoleContext = { unionOf: [agentRoleId] };
} else {
return await this.toolAdapterService.getTools();
}
const actionTools = await this.toolAdapterService.getTools(
effectiveRoleContext,
workspaceId,
);
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,
@@ -103,14 +128,35 @@ export class AgentAsyncExecutorService {
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const tools = agent
? await this.getTools(
agent.id,
agent.workspaceId,
actorContext,
rolePermissionConfig,
)
: {};
let tools: ToolSet = {};
let providerOptions = {};
if (agent) {
tools = await this.getToolsForWorkflowExecution(
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 };
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
agent as unknown as Parameters<
typeof this.agentModelConfigService.getProviderOptions
>[1],
);
}
this.logger.log(`Generated ${Object.keys(tools).length} tools for agent`);
@@ -120,7 +166,22 @@ export class AgentAsyncExecutorService {
model: registeredModel.model,
prompt: userPrompt,
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
providerOptions,
experimental_telemetry: AI_TELEMETRY_CONFIG,
experimental_repairToolCall: async ({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
}) => {
return repairToolCall({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
model: registeredModel.model,
});
},
});
const agentSchema =
@@ -0,0 +1,411 @@
import { Injectable, Logger } from '@nestjs/common';
import {
convertToModelMessages,
stepCountIs,
streamText,
ToolSet,
UIDataTypes,
UIMessage,
UITools,
} from 'ai';
import { AppPath, type ActorMetadata } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { getAllSelectableColumnNames } from 'src/engine/api/utils/get-all-selectable-column-names.utils';
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 {
AgentException,
AgentExceptionCode,
} from 'src/engine/metadata-modules/ai/ai-agent/agent.exception';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { AGENT_SYSTEM_PROMPTS } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-system-prompts.const';
import { RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { repairToolCall } from 'src/engine/metadata-modules/ai/ai-agent/utils/repair-tool-call.util';
import { AIBillingService } from 'src/engine/metadata-modules/ai/ai-billing/services/ai-billing.service';
import { ToolHints } from 'src/engine/metadata-modules/ai/ai-chat-router/types/tool-hints.interface';
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 { 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 { 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
export { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
export interface StreamChatResponseResult {
stream: ReturnType<typeof streamText>;
timings: {
contextBuildTimeMs: number;
toolGenerationTimeMs: number;
aiRequestPrepTimeMs: number;
toolCount: number;
};
}
@Injectable()
export class AgentExecutionService {
private readonly logger = new Logger(AgentExecutionService.name);
constructor(
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly aiModelRegistryService: AiModelRegistryService,
private readonly agentToolGeneratorService: AgentToolGeneratorService,
private readonly agentModelConfigService: AgentModelConfigService,
private readonly aiBillingService: AIBillingService,
private readonly agentActorContextService: AgentActorContextService,
private readonly agentService: AgentService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async prepareAIRequestConfig({
messages,
system,
agent,
actorContext,
roleIds,
toolHints,
additionalTools,
}: {
system: string;
agent: FlatAgentWithRoleId | null;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
actorContext?: ActorMetadata;
roleIds?: string[];
toolHints?: ToolHints;
additionalTools?: ToolSet;
}) {
try {
if (agent) {
this.logger.log(
`Preparing AI request config for agent ${agent.id} with model ${agent.modelId}`,
);
}
const registeredModel =
await this.aiModelRegistryService.resolveModelForAgent(agent);
let tools: ToolSet = {};
let providerOptions;
if (agent) {
const baseTools =
await this.agentToolGeneratorService.generateToolsForAgent(
agent.id,
agent.workspaceId,
actorContext,
roleIds,
toolHints,
);
const nativeModelTools =
this.agentModelConfigService.getNativeModelTools(
registeredModel,
agent,
);
tools = {
...baseTools,
...nativeModelTools,
...(additionalTools || {}),
};
providerOptions = this.agentModelConfigService.getProviderOptions(
registeredModel,
agent,
);
}
this.logger.log(
`Generated ${Object.keys(tools).length} tools for agent (including ${Object.keys(additionalTools || {}).length} additional tools)`,
);
return {
system,
tools,
model: registeredModel.model,
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(AGENT_CONFIG.MAX_STEPS),
providerOptions,
experimental_telemetry: AI_TELEMETRY_CONFIG,
experimental_repairToolCall: async ({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
}: {
toolCall: {
type: 'tool-call';
toolCallId: string;
toolName: string;
input: string;
};
tools: Record<string, unknown>;
inputSchema: (toolCall: { toolName: string }) => unknown;
error: Error;
}) => {
return repairToolCall({
toolCall,
tools: toolsForRepair,
inputSchema,
error,
model: registeredModel.model,
});
},
};
} catch (error) {
this.logger.error(
`Failed to prepare AI request config for agent ${agent?.id ?? 'no agent'}`,
error instanceof Error ? error.stack : error,
);
throw error;
}
}
async getContextForSystemPrompt(
workspace: WorkspaceEntity,
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType,
userWorkspaceId: string,
) {
const { userWorkspaceRoleMap } =
await this.workspaceCacheService.getOrRecompute(workspace.id, [
'userWorkspaceRoleMap',
]);
const roleId = userWorkspaceRoleMap[userWorkspaceId];
if (!roleId) {
throw new AgentException(
'Failed to retrieve user role.',
AgentExceptionCode.ROLE_NOT_FOUND,
);
}
const workspaceDataSource =
await this.twentyORMGlobalManager.getDataSourceForWorkspace({
workspaceId: workspace.id,
});
const flatObjectMetadataMaps =
workspaceDataSource.internalContext.flatObjectMetadataMaps;
const flatFieldMetadataMaps =
workspaceDataSource.internalContext.flatFieldMetadataMaps;
const objectIdByNameSingular =
workspaceDataSource.internalContext.objectIdByNameSingular;
const objectMetadataPermissions = workspaceDataSource.permissionsPerRoleId;
const contextObject = (
await Promise.all(
recordIdsByObjectMetadataNameSingular.map(
async (recordsWithObjectMetadataNameSingular) => {
if (recordsWithObjectMetadataNameSingular.recordIds.length === 0) {
return [];
}
const objectMetadataId =
objectIdByNameSingular[
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular
];
const objectMetadataMapItem = objectMetadataId
? flatObjectMetadataMaps.byId[objectMetadataId]
: undefined;
if (!objectMetadataMapItem) {
this.logger.warn(
`Object metadata not found for ${recordsWithObjectMetadataNameSingular.objectMetadataNameSingular}`,
);
return [];
}
const repository = workspaceDataSource.getRepository(
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
{ unionOf: [roleId] },
);
const restrictedFields =
objectMetadataPermissions?.[roleId]?.[objectMetadataMapItem.id]
?.restrictedFields ?? {};
const hasRestrictedFields = Object.values(restrictedFields).some(
(field) => field.canRead === false,
);
const selectOptions = hasRestrictedFields
? getAllSelectableColumnNames({
restrictedFields,
objectMetadata: {
objectMetadataMapItem,
flatFieldMetadataMaps,
},
})
: undefined;
return (
await repository.find({
...(selectOptions && { select: selectOptions }),
where: {
id: In(recordsWithObjectMetadataNameSingular.recordIds),
},
})
).map((record) => {
return {
...record,
resourceUrl: this.workspaceDomainsService.buildWorkspaceURL({
workspace,
pathname: getAppPath(AppPath.RecordShowPage, {
objectNameSingular:
recordsWithObjectMetadataNameSingular.objectMetadataNameSingular,
objectRecordId: record.id,
}),
}),
};
});
},
),
)
).flat(2);
return JSON.stringify(contextObject);
}
async streamChatResponse({
workspace,
userWorkspaceId,
agentId,
messages,
recordIdsByObjectMetadataNameSingular,
toolHints,
additionalTools,
}: {
workspace: WorkspaceEntity;
userWorkspaceId: string;
agentId: string;
messages: UIMessage<unknown, UIDataTypes, UITools>[];
recordIdsByObjectMetadataNameSingular: RecordIdsByObjectMetadataNameSingularType;
toolHints?: ToolHints;
additionalTools?: ToolSet;
}): Promise<{
stream: ReturnType<typeof streamText>;
timings: {
contextBuildTimeMs: number;
toolGenerationTimeMs: number;
aiRequestPrepTimeMs: number;
toolCount: number;
};
contextInfo: {
contextString: string;
contextRecordCount: number;
contextSizeBytes: number;
};
}> {
try {
const agent = await this.agentService.findOneAgentById({
workspaceId: workspace.id,
id: agentId,
});
const contextBuildStart = Date.now();
let contextPart = '';
let contextRecordCount = 0;
if (recordIdsByObjectMetadataNameSingular.length > 0) {
contextPart = await this.getContextForSystemPrompt(
workspace,
recordIdsByObjectMetadataNameSingular,
userWorkspaceId,
);
try {
const contextData = JSON.parse(contextPart);
contextRecordCount = Array.isArray(contextData)
? contextData.length
: 0;
} catch (error) {
this.logger.warn('Failed to parse context for record count:', error);
}
}
const contextString = contextPart ? `\n\nCONTEXT:\n${contextPart}` : '';
const contextBuildTime = Date.now() - contextBuildStart;
const { actorContext, roleId } =
await this.agentActorContextService.buildUserAndAgentActorContext(
userWorkspaceId,
workspace.id,
);
const aiRequestPrepStart = Date.now();
const aiRequestConfig = await this.prepareAIRequestConfig({
system: `${AGENT_SYSTEM_PROMPTS.BASE}\n${AGENT_SYSTEM_PROMPTS.CHAT_ADDITIONS}\n\n${agent.prompt}${contextString}`,
agent,
messages,
actorContext,
roleIds: [roleId, ...(agent?.roleId ? [agent?.roleId] : [])],
toolHints,
additionalTools,
});
const aiRequestPrepTime = Date.now() - aiRequestPrepStart;
const toolCount = Object.keys(aiRequestConfig.tools || {}).length;
const toolGenerationTime = aiRequestPrepTime;
this.logger.log(
`Sending request to AI model with ${messages.length} messages and ${toolCount} tools`,
);
const model =
await this.aiModelRegistryService.resolveModelForAgent(agent);
const stream = streamText(aiRequestConfig);
stream.usage
.then((usage) => {
this.aiBillingService.calculateAndBillUsage(
model.modelId,
usage,
workspace.id,
agent.id,
);
})
.catch((usageError) => {
this.logger.error('Failed to get usage information:', usageError);
});
return {
stream,
timings: {
contextBuildTimeMs: contextBuildTime,
toolGenerationTimeMs: toolGenerationTime,
aiRequestPrepTimeMs: aiRequestPrepTime,
toolCount,
},
contextInfo: {
contextString: contextPart,
contextRecordCount,
contextSizeBytes: contextPart
? Buffer.byteLength(contextPart, 'utf8')
: 0,
},
};
} catch (error) {
this.logger.error('Error in streamChatResponse:', error);
throw new AgentException(
error instanceof Error
? error.message
: 'Failed to stream chat response',
AgentExceptionCode.AGENT_EXECUTION_FAILED,
);
}
}
}
@@ -0,0 +1,102 @@
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,
},
},
};
}
}
@@ -0,0 +1,251 @@
import { Injectable, Logger } from '@nestjs/common';
import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentService } from 'src/engine/metadata-modules/ai/ai-agent/agent.service';
import { type RecordIdsByObjectMetadataNameSingularType } from 'src/engine/metadata-modules/ai/ai-agent/types/recordIdsByObjectMetadataNameSingular.type';
import { type PlanStep } from 'src/engine/metadata-modules/ai/ai-chat-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,
private readonly agentService: AgentService,
) {}
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.agentService.findOneAgentByName({
name: step.agentName,
workspaceId: workspace.id,
});
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;
}
}
@@ -0,0 +1,173 @@
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 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,
) {}
async generateToolsForAgent(
agentId: string,
workspaceId: string,
actorContext?: ActorMetadata,
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 },
workspaceId,
actorContext,
toolHints,
);
tools = { ...tools, ...databaseTools };
const roleActionTools = await this.toolAdapterService.getTools(
{ intersectionOf: roleIds },
workspaceId,
);
tools = { ...tools, ...roleActionTools };
} catch (toolError) {
this.logger.warn(
`Failed to generate tools for agent ${agentId}: ${toolError.message}. Proceeding without tools.`,
);
}
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';
}
}
@@ -0,0 +1,6 @@
import { type LanguageModelUsage } from 'ai';
export interface AgentExecutionResult {
result: object;
usage: LanguageModelUsage;
}