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.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23454?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Abdul Rahman
2026-07-29 17:45:56 +05:30
committed by GitHub
parent 9dd9709e97
commit ea7863dc4e
11 changed files with 458 additions and 219 deletions
@@ -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,
@@ -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);
});
});
@@ -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 = {
@@ -46,7 +46,7 @@ export const createExecuteToolTool = (
toolRegistry: ToolRegistryService,
context: ToolContext,
options?: {
excludeTools?: Set<string>;
isToolAllowed?: (toolName: string) => boolean;
compactOutput?: boolean;
spillLargeOutput?: boolean;
},
@@ -57,7 +57,7 @@ export const createExecuteToolTool = (
execute: async (parameters: ExecuteToolInput): Promise<ToolOutput> => {
const { toolName, arguments: args = {} } = parameters;
if (options?.excludeTools?.has(toolName)) {
if (options?.isToolAllowed?.(toolName) === false) {
return {
success: false,
message: `Tool "${toolName}" is not available`,
@@ -44,7 +44,7 @@ export type LearnToolsResult = {
};
export type LearnToolsOptions = {
excludeTools?: Set<string>;
isToolAllowed?: (toolName: string) => boolean;
spillLargeOutput?: boolean;
};
@@ -59,9 +59,9 @@ export const createLearnToolsTool = (
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
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(
@@ -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<string>,
categoryLabel: string,
): string => {
const operationOrder: string[] = [];
const seenOps = new Set<string>();
const objectToolsMap = new Map<string, string[]>();
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<string, ToolIndexEntry[]>();
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');
};
@@ -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>(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 () => {
@@ -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<ToolSet> {
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<string>(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<AgentExecutionResult> {
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,
@@ -73,6 +73,7 @@ export class AgentRunService {
workspaceId: workspace.id,
userWorkspaceId: requestUserWorkspaceId,
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
toolLoadingStrategy: 'lazy',
});
if (hasNoMoreAvailableCredits) {
@@ -0,0 +1 @@
export type AgentToolLoadingStrategy = 'preload' | 'lazy';
@@ -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<string, ToolIndexEntry[]>();
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<string>,
categoryLabel: string,
): string {
const operationOrder: string[] = [];
const seenOps = new Set<string>();
const objectToolsMap = new Map<string, string[]>();
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);
}
}
}