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); - } - } }