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:
+47
-3
@@ -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 () => {
|
||||
|
||||
+143
-32
@@ -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,
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ export class AgentRunService {
|
||||
workspaceId: workspace.id,
|
||||
userWorkspaceId: requestUserWorkspaceId,
|
||||
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||
toolLoadingStrategy: 'lazy',
|
||||
});
|
||||
|
||||
if (hasNoMoreAvailableCredits) {
|
||||
|
||||
Reference in New Issue
Block a user