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. Review in cubic --- .../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); - } - } }