From ea7863dc4e20c91ae69bb3eca3ce67a17e0220ea Mon Sep 17 00:00:00 2001
From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com>
Date: Wed, 29 Jul 2026 17:45:56 +0530
Subject: [PATCH] feat(ai-agent): lazy tool loading for the open-ended runAgent
path (#23454)
## Context
`AgentAsyncExecutorService.executeAgent` is shared by workflow agent
nodes and the `runAgent` mutation (Slack assistant, apps). Workflow
nodes run a scoped task, so pre-loading the few explicitly-granted
object tools is fast and skips
the `learn_tools` round trip (the behavior settled in #23400 / #23358).
`runAgent` is open-ended: its role grants broad object access, so
pre-loading inlines every CRUD/action schema on every step. That is what
makes the Slack assistant take 3-4 minutes for a prompt Ask AI answers
in seconds. Confirmed still slow with #23400 merged, so this is the
payload, not object scoping.
## What
Add a `toolLoadingStrategy` to `executeAgent` (default `'preload'`, so
workflow nodes and evals are unchanged). `AgentRunService.run` opts into
`'lazy'`, which exposes a compact tool catalog in the system prompt plus
the `learn_tools` / `execute_tool` meta-tools, using composed role
permissions rather than explicit grants only, so the agent keeps broad
access without the full-payload latency.
## How
- Split tool provisioning into two focused methods on the executor; a
3-line dispatch chooses per strategy. The pre-load path is unchanged.
- `buildLazyRegistryToolset`: one reusable definition of lazy registry
provisioning (catalog + meta-tools), so the chat and agent executors can
share it.
- Extract `buildToolCatalogSection` out of `SystemPromptBuilderService`
into a `tool-provider` util so both paths format the catalog identically
(no dup).
- Replace the meta-tools' `excludeTools` denylist with a single
`isToolAllowed` predicate: the agent path passes an allowlist closed
over the shown catalog (enforced at call time), MCP passes its existing
deny predicate.
Workflow node and eval behavior is unchanged.
---
.../api/mcp/services/mcp-protocol.service.ts | 4 +-
.../tools/__tests__/execute-tool.tool.spec.ts | 60 ++++++
.../tools/__tests__/learn-tools.tool.spec.ts | 29 ++-
.../tool-provider/tools/execute-tool.tool.ts | 4 +-
.../tool-provider/tools/learn-tools.tool.ts | 8 +-
.../utils/build-tool-catalog-section.util.ts | 166 ++++++++++++++++
.../agent-async-executor.service.spec.ts | 50 ++++-
.../services/agent-async-executor.service.ts | 175 +++++++++++++----
.../services/agent-run.service.ts | 1 +
.../types/agent-tool-loading-strategy.type.ts | 1 +
.../services/system-prompt-builder.service.ts | 179 +-----------------
11 files changed, 458 insertions(+), 219 deletions(-)
create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/execute-tool.tool.spec.ts
create mode 100644 packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type.ts
diff --git a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts
index 7526287c52..e4b4e87031 100644
--- a/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts
+++ b/packages/twenty-server/src/engine/api/mcp/services/mcp-protocol.service.ts
@@ -234,7 +234,7 @@ export class McpProtocolService {
} as McpAnnotatedTool,
[EXECUTE_TOOL_TOOL_NAME]: {
...createExecuteToolTool(this.toolRegistry, toolContext, {
- excludeTools: MCP_EXCLUDED_TOOL_NAMES,
+ isToolAllowed: (toolName) => !MCP_EXCLUDED_TOOL_NAMES.has(toolName),
}),
inputSchema: executeToolInputSchema,
annotations: MCP_EXECUTE_TOOL_ANNOTATIONS,
@@ -269,7 +269,7 @@ export class McpProtocolService {
} as McpAnnotatedTool,
[LEARN_TOOLS_TOOL_NAME]: {
...createLearnToolsTool(this.toolRegistry, toolContext, {
- excludeTools: MCP_EXCLUDED_TOOL_NAMES,
+ isToolAllowed: (toolName) => !MCP_EXCLUDED_TOOL_NAMES.has(toolName),
}),
inputSchema: zodSchema(learnToolsInputSchema),
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/execute-tool.tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/execute-tool.tool.spec.ts
new file mode 100644
index 0000000000..501a18fd60
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/execute-tool.tool.spec.ts
@@ -0,0 +1,60 @@
+import { type ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
+import { createExecuteToolTool } from 'src/engine/core-modules/tool-provider/tools/execute-tool.tool';
+import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
+
+describe('createExecuteToolTool', () => {
+ const context = {} as ToolContext;
+
+ const buildRegistry = () =>
+ ({
+ resolveAndExecute: jest
+ .fn()
+ .mockResolvedValue({ success: true, result: {} }),
+ }) as unknown as ToolRegistryService;
+
+ it('executes tools the predicate allows', async () => {
+ const toolRegistry = buildRegistry();
+
+ const executeTool = createExecuteToolTool(toolRegistry, context, {
+ isToolAllowed: (toolName) => toolName === 'find_many_people',
+ });
+
+ const result = await executeTool.execute({
+ toolName: 'find_many_people',
+ arguments: {},
+ });
+
+ expect(toolRegistry.resolveAndExecute).toHaveBeenCalledTimes(1);
+ expect(result.success).toBe(true);
+ });
+
+ it('refuses tools the predicate rejects without touching the registry', async () => {
+ const toolRegistry = buildRegistry();
+
+ const executeTool = createExecuteToolTool(toolRegistry, context, {
+ isToolAllowed: (toolName) => toolName === 'find_many_people',
+ });
+
+ const result = await executeTool.execute({
+ toolName: 'create_one_workflow',
+ arguments: {},
+ });
+
+ expect(toolRegistry.resolveAndExecute).not.toHaveBeenCalled();
+ expect(result.success).toBe(false);
+ });
+
+ it('executes any tool when no predicate is provided', async () => {
+ const toolRegistry = buildRegistry();
+
+ const executeTool = createExecuteToolTool(toolRegistry, context);
+
+ const result = await executeTool.execute({
+ toolName: 'find_many_people',
+ arguments: {},
+ });
+
+ expect(toolRegistry.resolveAndExecute).toHaveBeenCalledTimes(1);
+ expect(result.success).toBe(true);
+ });
+});
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts
index cbbd6635d7..538a556fda 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/__tests__/learn-tools.tool.spec.ts
@@ -76,7 +76,7 @@ describe('createLearnToolsTool', () => {
);
});
- it('does not report excluded tools as not found or suggest alternatives', async () => {
+ it('does not report disallowed tools as not found or suggest alternatives', async () => {
const suggestSimilarToolNames = jest.fn();
const toolRegistry = {
getToolInfo: jest.fn().mockResolvedValue([]),
@@ -84,7 +84,7 @@ describe('createLearnToolsTool', () => {
} as unknown as ToolRegistryService;
const learnTools = createLearnToolsTool(toolRegistry, context, {
- excludeTools: new Set(['code_interpreter']),
+ isToolAllowed: (toolName) => toolName !== 'code_interpreter',
});
const result = await learnTools.execute({
@@ -101,6 +101,31 @@ describe('createLearnToolsTool', () => {
expect(result.message).toBe('No matching tools found.');
});
+ it('only learns tools the predicate allows', async () => {
+ const suggestSimilarToolNames = jest.fn();
+ const toolRegistry = {
+ getToolInfo: jest.fn().mockResolvedValue([{ name: 'find_many_people' }]),
+ suggestSimilarToolNames,
+ } as unknown as ToolRegistryService;
+
+ const learnTools = createLearnToolsTool(toolRegistry, context, {
+ isToolAllowed: (toolName) => toolName === 'find_many_people',
+ });
+
+ const result = await learnTools.execute({
+ toolNames: ['find_many_people', 'create_one_workflow'],
+ aspects: ['description'],
+ });
+
+ expect(toolRegistry.getToolInfo).toHaveBeenCalledWith(
+ ['find_many_people'],
+ context,
+ ['description'],
+ );
+ expect(result.notFound).toEqual([]);
+ expect(suggestSimilarToolNames).not.toHaveBeenCalled();
+ });
+
it('does not consult the spill service when spillLargeOutput is not set', async () => {
const spillToolOutputIfTooLarge = jest.fn();
const toolRegistry = {
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts
index 4b989c0206..292e6c9d58 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/execute-tool.tool.ts
@@ -46,7 +46,7 @@ export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
options?: {
- excludeTools?: Set;
+ isToolAllowed?: (toolName: string) => boolean;
compactOutput?: boolean;
spillLargeOutput?: boolean;
},
@@ -57,7 +57,7 @@ export const createExecuteToolTool = (
execute: async (parameters: ExecuteToolInput): Promise => {
const { toolName, arguments: args = {} } = parameters;
- if (options?.excludeTools?.has(toolName)) {
+ if (options?.isToolAllowed?.(toolName) === false) {
return {
success: false,
message: `Tool "${toolName}" is not available`,
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts
index 006a5e3d58..431d7904aa 100644
--- a/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/tools/learn-tools.tool.ts
@@ -44,7 +44,7 @@ export type LearnToolsResult = {
};
export type LearnToolsOptions = {
- excludeTools?: Set;
+ isToolAllowed?: (toolName: string) => boolean;
spillLargeOutput?: boolean;
};
@@ -59,9 +59,9 @@ export const createLearnToolsTool = (
execute: async (parameters: LearnToolsInput): Promise => {
const { toolNames, aspects } = parameters;
- const excludeTools = options?.excludeTools;
- const allowedNames = excludeTools
- ? toolNames.filter((name) => !excludeTools.has(name))
+ const { isToolAllowed } = options ?? {};
+ const allowedNames = isToolAllowed
+ ? toolNames.filter((name) => isToolAllowed(name))
: toolNames;
const toolInfos = await toolRegistry.getToolInfo(
diff --git a/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts
new file mode 100644
index 0000000000..d17d933801
--- /dev/null
+++ b/packages/twenty-server/src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util.ts
@@ -0,0 +1,166 @@
+import { ToolCategory } from 'twenty-shared/ai';
+import { assertUnreachable } from 'twenty-shared/utils';
+
+import {
+ EXECUTE_TOOL_TOOL_NAME,
+ LEARN_TOOLS_TOOL_NAME,
+} from 'src/engine/core-modules/tool-provider/tools';
+import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
+
+const getCategoryLabel = (category: ToolCategory): string => {
+ switch (category) {
+ case ToolCategory.DATABASE_CRUD:
+ return 'Database Tools (CRUD operations)';
+ case ToolCategory.ACTION:
+ return 'Action Tools (HTTP, Email, etc.)';
+ case ToolCategory.WORKFLOW:
+ return 'Workflow Tools (create/manage workflows)';
+ case ToolCategory.METADATA:
+ return 'Metadata Tools (schema management)';
+ case ToolCategory.VIEW:
+ return 'View Tools (manage views, fields, filters, and sorts)';
+ case ToolCategory.DASHBOARD:
+ return 'Dashboard Tools (create/manage dashboards)';
+ case ToolCategory.LOGIC_FUNCTION:
+ return 'Logic Functions (custom tools)';
+ case ToolCategory.NAVIGATION_MENU_ITEM:
+ return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)';
+ case ToolCategory.WEBHOOK:
+ return 'Webhook Tools (outgoing webhooks)';
+ default:
+ return assertUnreachable(category);
+ }
+};
+
+const buildDatabaseCrudCatalogSection = (
+ tools: ToolIndexEntry[],
+ preloadedSet: Set,
+ categoryLabel: string,
+): string => {
+ const operationOrder: string[] = [];
+ const seenOps = new Set();
+
+ const objectToolsMap = new Map();
+ const standaloneTools: ToolIndexEntry[] = [];
+
+ for (const tool of tools) {
+ if (tool.objectName && tool.operation) {
+ const ops = objectToolsMap.get(tool.objectName) ?? [];
+
+ ops.push(tool.operation);
+ objectToolsMap.set(tool.objectName, ops);
+
+ if (!seenOps.has(tool.operation)) {
+ seenOps.add(tool.operation);
+ operationOrder.push(tool.operation);
+ }
+ } else {
+ standaloneTools.push(tool);
+ }
+ }
+
+ const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
+
+ if (objectToolsMap.size > 0) {
+ const objectNames = [...objectToolsMap.keys()].sort();
+
+ lines.push(`Operations per object:`);
+ lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
+
+ lines.push(`\nObjects (${objectNames.length}):`);
+ lines.push(...objectNames.map((name) => `- \`${name}\``));
+
+ const findManyExample = tools.find((t) => t.operation === 'find_many');
+ const findOneExample = tools.find(
+ (t) =>
+ t.operation === 'find_one' &&
+ t.objectName === findManyExample?.objectName,
+ );
+ const examplePart =
+ findManyExample && findOneExample
+ ? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
+ : '';
+
+ lines.push(
+ `\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
+ );
+ }
+
+ for (const tool of standaloneTools) {
+ const status = preloadedSet.has(tool.name) ? ' ✓' : '';
+
+ lines.push(`- \`${tool.name}\`${status}`);
+ }
+
+ return lines.join('\n');
+};
+
+export const buildToolCatalogSection = (
+ toolCatalog: ToolIndexEntry[],
+ preloadedTools: string[],
+): string => {
+ const preloadedSet = new Set(preloadedTools);
+
+ const toolsByCategory = new Map();
+
+ for (const tool of toolCatalog) {
+ const category = tool.category;
+ const existing = toolsByCategory.get(category) ?? [];
+
+ existing.push(tool);
+ toolsByCategory.set(category, existing);
+ }
+
+ const sections: string[] = [];
+
+ const preloadedList =
+ preloadedTools.length > 0
+ ? preloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n')
+ : '(none)';
+
+ sections.push(`
+## Available Tools
+
+You have access to ${toolCatalog.length} tools. Some are pre-loaded and ready to use immediately.
+To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it.
+
+### Pre-loaded Tools (ready to use now)
+${preloadedList}
+
+### Tool Catalog by Category`);
+
+ const categoryOrder = Object.values(ToolCategory);
+
+ for (const category of categoryOrder) {
+ const tools = toolsByCategory.get(category);
+
+ if (!tools || tools.length === 0) {
+ continue;
+ }
+
+ const categoryLabel = getCategoryLabel(category);
+
+ if (category === ToolCategory.DATABASE_CRUD) {
+ sections.push(
+ buildDatabaseCrudCatalogSection(tools, preloadedSet, categoryLabel),
+ );
+ } else {
+ sections.push(`
+#### ${categoryLabel} (${tools.length} tools)
+${tools
+ .map((tool) => {
+ const status = preloadedSet.has(tool.name) ? ' ✓' : '';
+
+ return `- \`${tool.name}\`${status}`;
+ })
+ .join('\n')}`);
+ }
+ }
+
+ sections.push(`
+### How to Use Tools
+1. **Pre-loaded tools** (marked with ✓): Use directly
+2. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`);
+
+ return sections.join('\n');
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
index 72caf18b52..ef7afd0727 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/__tests__/agent-async-executor.service.spec.ts
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { generateText } from 'ai';
+import { ToolCategory } from 'twenty-shared/ai';
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
@@ -44,7 +45,10 @@ const generateTextMock = generateText as jest.MockedFunction<
describe('AgentAsyncExecutorService — workflow agent role-scoped tool resolution', () => {
let service: AgentAsyncExecutorService;
- let toolRegistry: { getToolsByCategories: jest.Mock };
+ let toolRegistry: {
+ getToolsByCategories: jest.Mock;
+ buildToolIndex: jest.Mock;
+ };
let roleTargetRepository: { findOne: jest.Mock };
let aiBillingService: {
decrementAndCheckAvailableCredits: jest.Mock;
@@ -79,7 +83,10 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
};
beforeEach(async () => {
- toolRegistry = { getToolsByCategories: jest.fn().mockResolvedValue({}) };
+ toolRegistry = {
+ getToolsByCategories: jest.fn().mockResolvedValue({}),
+ buildToolIndex: jest.fn().mockResolvedValue([]),
+ };
roleTargetRepository = { findOne: jest.fn() };
aiBillingService = {
decrementAndCheckAvailableCredits: jest
@@ -146,7 +153,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
service = module.get(AgentAsyncExecutorService);
});
- it('passes intersectionOf: [agentRoleId] when the agent has a role assigned', async () => {
+ it('preloads role-scoped tool schemas by default (workflow node)', async () => {
roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
await service.executeAgent({
@@ -161,10 +168,47 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
expect.objectContaining({
roleId: agentRoleId,
rolePermissionConfig: { intersectionOf: [agentRoleId] },
+ requireExplicitObjectGrants: true,
workspaceId,
}),
expect.objectContaining({ wrapWithErrorContext: false }),
);
+ expect(toolRegistry.buildToolIndex).not.toHaveBeenCalled();
+ });
+
+ it('loads tools lazily via a category-scoped catalog when toolLoadingStrategy is "lazy" (runAgent)', async () => {
+ roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
+ toolRegistry.buildToolIndex.mockResolvedValueOnce([
+ {
+ name: 'find_many_people',
+ category: ToolCategory.DATABASE_CRUD,
+ objectName: 'person',
+ operation: 'find_many',
+ },
+ { name: 'create_one_workflow', category: ToolCategory.WORKFLOW },
+ ]);
+
+ await service.executeAgent({
+ agent: buildAgent(),
+ userPrompt: 'test',
+ baseSystemPrompt: 'base system prompt',
+ workspaceId,
+ toolLoadingStrategy: 'lazy',
+ });
+
+ expect(toolRegistry.getToolsByCategories).not.toHaveBeenCalled();
+ expect(toolRegistry.buildToolIndex).toHaveBeenCalledWith(
+ workspaceId,
+ agentRoleId,
+ expect.any(Object),
+ );
+
+ const { system } = generateTextMock.mock.calls[0][0];
+
+ expect(system).toContain('## Available Tools');
+ expect(system).toContain('person');
+ expect(system).not.toContain('Workflow Tools');
+ expect(system).not.toContain('create_one_workflow');
});
it('does not resolve registry tools when the agent has no role (fail-closed)', async () => {
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
index 8cd7a84844..b33ac6687b 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-async-executor.service.ts
@@ -24,6 +24,14 @@ import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service'
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { type ToolProviderContext } from 'src/engine/core-modules/tool-provider/interfaces/tool-provider-context.type';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
+import {
+ createExecuteToolTool,
+ createLearnToolsTool,
+ EXECUTE_TOOL_TOOL_NAME,
+ LEARN_TOOLS_TOOL_NAME,
+} from 'src/engine/core-modules/tool-provider/tools';
+import { type ToolContext } from 'src/engine/core-modules/tool-provider/types/tool-context.type';
+import { buildToolCatalogSection } from 'src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util';
import { estimateToolOutputTokens } from 'src/engine/core-modules/tool-provider/utils/estimate-tool-output-tokens.util';
import { getToolMetricName } from 'src/engine/core-modules/tool-provider/utils/get-tool-metric-name.util';
import { isToolOutputSuccessful } from 'src/engine/core-modules/tool-provider/utils/is-tool-output-successful.util';
@@ -32,6 +40,7 @@ import { UsageOperationType } from 'src/engine/core-modules/usage/enums/usage-op
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES } from 'src/engine/metadata-modules/ai/ai-agent-execution/constants/workflow-agent-registry-tool-categories.const';
import { type AgentExecutionResult } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-execution-result.type';
+import { type AgentToolLoadingStrategy } from 'src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type';
import { AGENT_CONFIG } from 'src/engine/metadata-modules/ai/ai-agent/constants/agent-config.const';
import { STRUCTURED_OUTPUT_SYSTEM_PROMPT } from 'src/engine/metadata-modules/ai/ai-agent/constants/structured-output-system-prompt.const';
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
@@ -56,7 +65,6 @@ import {
AiExceptionCode,
} from 'src/engine/metadata-modules/ai/ai.exception';
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 { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator';
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
@@ -111,6 +119,117 @@ export class AgentAsyncExecutorService {
return roleTarget?.roleId;
}
+ private resolveUserIdentity(authContext?: WorkspaceAuthContext): {
+ userId?: string;
+ userWorkspaceId?: string;
+ } {
+ if (isDefined(authContext) && isUserAuthContext(authContext)) {
+ return {
+ userId: authContext.user.id,
+ userWorkspaceId: authContext.userWorkspaceId,
+ };
+ }
+
+ return {};
+ }
+
+ // Workflow agent nodes run a scoped task: pre-load the full schemas of the
+ // few explicitly-granted objects so the model skips the learn_tools round trip.
+ private async buildPreloadedRegistryTools({
+ agent,
+ agentRoleId,
+ authContext,
+ actorContext,
+ }: {
+ agent: AgentEntity;
+ agentRoleId: string;
+ authContext?: WorkspaceAuthContext;
+ actorContext?: ActorMetadata;
+ }): Promise {
+ const { userId, userWorkspaceId } = this.resolveUserIdentity(authContext);
+
+ const toolProviderContext: ToolProviderContext = {
+ workspaceId: agent.workspaceId,
+ roleId: agentRoleId,
+ rolePermissionConfig: { intersectionOf: [agentRoleId] },
+ requireExplicitObjectGrants: true,
+ authContext,
+ actorContext,
+ userId,
+ userWorkspaceId,
+ };
+
+ return this.toolRegistry.getToolsByCategories(toolProviderContext, {
+ categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES,
+ excludeTools: [...OUTPUT_NAVIGATION_TOOL_NAMES],
+ wrapWithErrorContext: false,
+ });
+ }
+
+ // Open-ended agents (runAgent / Slack) need broad object access, which would
+ // make pre-loading ship every schema. Expose a compact catalog plus the
+ // learn_tools / execute_tool meta-tools instead, using composed role
+ // permissions rather than explicit grants only.
+ private async buildLazyRegistryTools({
+ agent,
+ agentRoleId,
+ authContext,
+ actorContext,
+ }: {
+ agent: AgentEntity;
+ agentRoleId: string;
+ authContext?: WorkspaceAuthContext;
+ actorContext?: ActorMetadata;
+ }): Promise<{ tools: ToolSet; catalogSection: string }> {
+ const { userId, userWorkspaceId } = this.resolveUserIdentity(authContext);
+
+ const toolContext: ToolContext = {
+ workspaceId: agent.workspaceId,
+ roleId: agentRoleId,
+ authContext,
+ actorContext,
+ userId,
+ userWorkspaceId,
+ };
+
+ const fullCatalog = await this.toolRegistry.buildToolIndex(
+ agent.workspaceId,
+ agentRoleId,
+ { userId, userWorkspaceId },
+ );
+
+ const allowedCategories = new Set(WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES);
+ const excludedToolNames = new Set(OUTPUT_NAVIGATION_TOOL_NAMES);
+
+ const catalog = fullCatalog.filter(
+ (entry) =>
+ allowedCategories.has(entry.category) &&
+ !excludedToolNames.has(entry.name),
+ );
+
+ // Restrict the meta-tools to the shown catalog. Enforced at call time, so a
+ // tool that appears after the catalog was built still can't be reached,
+ // preserving the recursion guard.
+ const allowedToolNames = new Set(catalog.map((entry) => entry.name));
+ const isToolAllowed = (toolName: string): boolean =>
+ allowedToolNames.has(toolName);
+
+ const tools: ToolSet = {
+ [LEARN_TOOLS_TOOL_NAME]: createLearnToolsTool(
+ this.toolRegistry,
+ toolContext,
+ { isToolAllowed, spillLargeOutput: true },
+ ),
+ [EXECUTE_TOOL_TOOL_NAME]: createExecuteToolTool(
+ this.toolRegistry,
+ toolContext,
+ { isToolAllowed, compactOutput: true, spillLargeOutput: true },
+ ),
+ };
+
+ return { tools, catalogSection: buildToolCatalogSection(catalog, []) };
+ }
+
async executeAgent({
agent,
userPrompt,
@@ -120,6 +239,7 @@ export class AgentAsyncExecutorService {
workspaceId,
userWorkspaceId,
operationType = UsageOperationType.AI_WORKFLOW_TOKEN,
+ toolLoadingStrategy = 'preload',
}: {
agent: AgentEntity | null;
userPrompt: string;
@@ -129,6 +249,7 @@ export class AgentAsyncExecutorService {
workspaceId: string;
userWorkspaceId?: string | null;
operationType?: UsageOperationType;
+ toolLoadingStrategy?: AgentToolLoadingStrategy;
}): Promise {
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
@@ -155,6 +276,7 @@ export class AgentAsyncExecutorService {
await this.aiModelRegistryService.resolveModelForAgent(agent);
let tools: ToolSet = {};
+ let toolCatalogSection = '';
let providerOptions = getCallLevelProviderOptions({
sdkPackage: registeredModel.sdkPackage,
providerOptions: undefined,
@@ -175,38 +297,27 @@ export class AgentAsyncExecutorService {
let registryTools: ToolSet = {};
- // Workflow agent registry tools are scoped exclusively by the agent
- // permission-tab role. No role means no registry tools.
+ // Registry tools are scoped exclusively by the agent permission-tab
+ // role. No role means no registry tools.
if (isDefined(agentRoleId)) {
- const agentRolePermissionConfig: RolePermissionConfig = {
- intersectionOf: [agentRoleId],
- };
+ if (toolLoadingStrategy === 'lazy') {
+ const lazyToolset = await this.buildLazyRegistryTools({
+ agent,
+ agentRoleId,
+ authContext,
+ actorContext,
+ });
- const toolProviderContext: ToolProviderContext = {
- workspaceId: agent.workspaceId,
- roleId: agentRoleId,
- rolePermissionConfig: agentRolePermissionConfig,
- requireExplicitObjectGrants: true,
- authContext,
- actorContext,
- userId:
- isDefined(authContext) && isUserAuthContext(authContext)
- ? authContext.user.id
- : undefined,
- userWorkspaceId:
- isDefined(authContext) && isUserAuthContext(authContext)
- ? authContext.userWorkspaceId
- : undefined,
- };
-
- registryTools = await this.toolRegistry.getToolsByCategories(
- toolProviderContext,
- {
- categories: WORKFLOW_AGENT_REGISTRY_TOOL_CATEGORIES,
- excludeTools: [...OUTPUT_NAVIGATION_TOOL_NAMES],
- wrapWithErrorContext: false,
- },
- );
+ registryTools = lazyToolset.tools;
+ toolCatalogSection = lazyToolset.catalogSection;
+ } else {
+ registryTools = await this.buildPreloadedRegistryTools({
+ agent,
+ agentRoleId,
+ authContext,
+ actorContext,
+ });
+ }
}
const nativeTools = this.nativeToolBinder.bind(
@@ -234,7 +345,7 @@ export class AgentAsyncExecutorService {
let hasNoMoreAvailableCredits = false;
const textResponse = await generateText({
- system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}`,
+ system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}${toolCatalogSection}`,
tools,
model: registeredModel.model,
prompt: userPrompt,
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
index 0b59748930..c6abaf2e91 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/services/agent-run.service.ts
@@ -73,6 +73,7 @@ export class AgentRunService {
workspaceId: workspace.id,
userWorkspaceId: requestUserWorkspaceId,
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
+ toolLoadingStrategy: 'lazy',
});
if (hasNoMoreAvailableCredits) {
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type.ts
new file mode 100644
index 0000000000..72b398af69
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-agent-execution/types/agent-tool-loading-strategy.type.ts
@@ -0,0 +1 @@
+export type AgentToolLoadingStrategy = 'preload' | 'lazy';
diff --git a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts
index 8efb2d6691..76cd393ce3 100644
--- a/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/system-prompt-builder.service.ts
@@ -1,19 +1,12 @@
import { Injectable } from '@nestjs/common';
-import {
- assertUnreachable,
- getValidTimeZoneOrUndefined,
-} from 'twenty-shared/utils';
+import { getValidTimeZoneOrUndefined } from 'twenty-shared/utils';
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
-import { ToolCategory } from 'twenty-shared/ai';
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
-import {
- EXECUTE_TOOL_TOOL_NAME,
- LEARN_TOOLS_TOOL_NAME,
- LOAD_SKILL_TOOL_NAME,
-} from 'src/engine/core-modules/tool-provider/tools';
+import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools';
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
+import { buildToolCatalogSection } from 'src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util';
import {
AgentActorContextService,
type UserContext,
@@ -103,7 +96,7 @@ export class SystemPromptBuilderService {
});
}
- const toolSection = this.buildToolCatalogSection(
+ const toolSection = buildToolCatalogSection(
toolCatalog,
COMMON_PRELOAD_TOOLS,
);
@@ -160,7 +153,7 @@ export class SystemPromptBuilderService {
parts.push(this.buildUserContextSection(userContext));
}
- parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
+ parts.push(buildToolCatalogSection(toolCatalog, preloadedTools));
const skillSection = this.buildSkillCatalogSection(skillCatalog);
@@ -257,166 +250,4 @@ To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s).
${skillsList}`;
}
-
- buildToolCatalogSection(
- toolCatalog: ToolIndexEntry[],
- preloadedTools: string[],
- ): string {
- const preloadedSet = new Set(preloadedTools);
-
- const toolsByCategory = new Map();
-
- for (const tool of toolCatalog) {
- const category = tool.category;
- const existing = toolsByCategory.get(category) ?? [];
-
- existing.push(tool);
- toolsByCategory.set(category, existing);
- }
-
- const sections: string[] = [];
-
- const preloadedList =
- preloadedTools.length > 0
- ? preloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n')
- : '(none)';
-
- sections.push(`
-## Available Tools
-
-You have access to ${toolCatalog.length} tools. Some are pre-loaded and ready to use immediately.
-To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it.
-
-### Pre-loaded Tools (ready to use now)
-${preloadedList}
-
-### Tool Catalog by Category`);
-
- const categoryOrder = Object.values(ToolCategory);
-
- for (const category of categoryOrder) {
- const tools = toolsByCategory.get(category);
-
- if (!tools || tools.length === 0) {
- continue;
- }
-
- const categoryLabel = this.getCategoryLabel(category);
-
- if (category === ToolCategory.DATABASE_CRUD) {
- sections.push(
- this.buildDatabaseCrudCatalogSection(
- tools,
- preloadedSet,
- categoryLabel,
- ),
- );
- } else {
- sections.push(`
-#### ${categoryLabel} (${tools.length} tools)
-${tools
- .map((tool) => {
- const status = preloadedSet.has(tool.name) ? ' ✓' : '';
-
- return `- \`${tool.name}\`${status}`;
- })
- .join('\n')}`);
- }
- }
-
- sections.push(`
-### How to Use Tools
-1. **Pre-loaded tools** (marked with ✓): Use directly
-2. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`);
-
- return sections.join('\n');
- }
-
- private buildDatabaseCrudCatalogSection(
- tools: ToolIndexEntry[],
- preloadedSet: Set,
- categoryLabel: string,
- ): string {
- const operationOrder: string[] = [];
- const seenOps = new Set();
-
- const objectToolsMap = new Map();
- const standaloneTools: ToolIndexEntry[] = [];
-
- for (const tool of tools) {
- if (tool.objectName && tool.operation) {
- const ops = objectToolsMap.get(tool.objectName) ?? [];
-
- ops.push(tool.operation);
- objectToolsMap.set(tool.objectName, ops);
-
- if (!seenOps.has(tool.operation)) {
- seenOps.add(tool.operation);
- operationOrder.push(tool.operation);
- }
- } else {
- standaloneTools.push(tool);
- }
- }
-
- const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
-
- if (objectToolsMap.size > 0) {
- const objectNames = [...objectToolsMap.keys()].sort();
-
- lines.push(`Operations per object:`);
- lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
-
- lines.push(`\nObjects (${objectNames.length}):`);
- lines.push(...objectNames.map((name) => `- \`${name}\``));
-
- const findManyExample = tools.find((t) => t.operation === 'find_many');
- const findOneExample = tools.find(
- (t) =>
- t.operation === 'find_one' &&
- t.objectName === findManyExample?.objectName,
- );
- const examplePart =
- findManyExample && findOneExample
- ? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
- : '';
-
- lines.push(
- `\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
- );
- }
-
- for (const tool of standaloneTools) {
- const status = preloadedSet.has(tool.name) ? ' ✓' : '';
-
- lines.push(`- \`${tool.name}\`${status}`);
- }
-
- return lines.join('\n');
- }
-
- private getCategoryLabel(category: ToolCategory): string {
- switch (category) {
- case ToolCategory.DATABASE_CRUD:
- return 'Database Tools (CRUD operations)';
- case ToolCategory.ACTION:
- return 'Action Tools (HTTP, Email, etc.)';
- case ToolCategory.WORKFLOW:
- return 'Workflow Tools (create/manage workflows)';
- case ToolCategory.METADATA:
- return 'Metadata Tools (schema management)';
- case ToolCategory.VIEW:
- return 'View Tools (manage views, fields, filters, and sorts)';
- case ToolCategory.DASHBOARD:
- return 'Dashboard Tools (create/manage dashboards)';
- case ToolCategory.LOGIC_FUNCTION:
- return 'Logic Functions (custom tools)';
- case ToolCategory.NAVIGATION_MENU_ITEM:
- return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)';
- case ToolCategory.WEBHOOK:
- return 'Webhook Tools (outgoing webhooks)';
- default:
- return assertUnreachable(category);
- }
- }
}