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
@@ -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) {