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:
@@ -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,
|
||||
|
||||
+60
@@ -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);
|
||||
});
|
||||
});
|
||||
+27
-2
@@ -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 = {
|
||||
|
||||
+2
-2
@@ -46,7 +46,7 @@ export const createExecuteToolTool = (
|
||||
toolRegistry: ToolRegistryService,
|
||||
context: ToolContext,
|
||||
options?: {
|
||||
excludeTools?: Set<string>;
|
||||
isToolAllowed?: (toolName: string) => boolean;
|
||||
compactOutput?: boolean;
|
||||
spillLargeOutput?: boolean;
|
||||
},
|
||||
@@ -57,7 +57,7 @@ export const createExecuteToolTool = (
|
||||
execute: async (parameters: ExecuteToolInput): Promise<ToolOutput> => {
|
||||
const { toolName, arguments: args = {} } = parameters;
|
||||
|
||||
if (options?.excludeTools?.has(toolName)) {
|
||||
if (options?.isToolAllowed?.(toolName) === false) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Tool "${toolName}" is not available`,
|
||||
|
||||
+4
-4
@@ -44,7 +44,7 @@ export type LearnToolsResult = {
|
||||
};
|
||||
|
||||
export type LearnToolsOptions = {
|
||||
excludeTools?: Set<string>;
|
||||
isToolAllowed?: (toolName: string) => boolean;
|
||||
spillLargeOutput?: boolean;
|
||||
};
|
||||
|
||||
@@ -59,9 +59,9 @@ export const createLearnToolsTool = (
|
||||
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
|
||||
const { toolNames, aspects } = parameters;
|
||||
|
||||
const excludeTools = options?.excludeTools;
|
||||
const allowedNames = excludeTools
|
||||
? toolNames.filter((name) => !excludeTools.has(name))
|
||||
const { isToolAllowed } = options ?? {};
|
||||
const allowedNames = isToolAllowed
|
||||
? toolNames.filter((name) => isToolAllowed(name))
|
||||
: toolNames;
|
||||
|
||||
const toolInfos = await toolRegistry.getToolInfo(
|
||||
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
|
||||
const getCategoryLabel = (category: ToolCategory): string => {
|
||||
switch (category) {
|
||||
case ToolCategory.DATABASE_CRUD:
|
||||
return 'Database Tools (CRUD operations)';
|
||||
case ToolCategory.ACTION:
|
||||
return 'Action Tools (HTTP, Email, etc.)';
|
||||
case ToolCategory.WORKFLOW:
|
||||
return 'Workflow Tools (create/manage workflows)';
|
||||
case ToolCategory.METADATA:
|
||||
return 'Metadata Tools (schema management)';
|
||||
case ToolCategory.VIEW:
|
||||
return 'View Tools (manage views, fields, filters, and sorts)';
|
||||
case ToolCategory.DASHBOARD:
|
||||
return 'Dashboard Tools (create/manage dashboards)';
|
||||
case ToolCategory.LOGIC_FUNCTION:
|
||||
return 'Logic Functions (custom tools)';
|
||||
case ToolCategory.NAVIGATION_MENU_ITEM:
|
||||
return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)';
|
||||
case ToolCategory.WEBHOOK:
|
||||
return 'Webhook Tools (outgoing webhooks)';
|
||||
default:
|
||||
return assertUnreachable(category);
|
||||
}
|
||||
};
|
||||
|
||||
const buildDatabaseCrudCatalogSection = (
|
||||
tools: ToolIndexEntry[],
|
||||
preloadedSet: Set<string>,
|
||||
categoryLabel: string,
|
||||
): string => {
|
||||
const operationOrder: string[] = [];
|
||||
const seenOps = new Set<string>();
|
||||
|
||||
const objectToolsMap = new Map<string, string[]>();
|
||||
const standaloneTools: ToolIndexEntry[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.objectName && tool.operation) {
|
||||
const ops = objectToolsMap.get(tool.objectName) ?? [];
|
||||
|
||||
ops.push(tool.operation);
|
||||
objectToolsMap.set(tool.objectName, ops);
|
||||
|
||||
if (!seenOps.has(tool.operation)) {
|
||||
seenOps.add(tool.operation);
|
||||
operationOrder.push(tool.operation);
|
||||
}
|
||||
} else {
|
||||
standaloneTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
|
||||
|
||||
if (objectToolsMap.size > 0) {
|
||||
const objectNames = [...objectToolsMap.keys()].sort();
|
||||
|
||||
lines.push(`Operations per object:`);
|
||||
lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
|
||||
|
||||
lines.push(`\nObjects (${objectNames.length}):`);
|
||||
lines.push(...objectNames.map((name) => `- \`${name}\``));
|
||||
|
||||
const findManyExample = tools.find((t) => t.operation === 'find_many');
|
||||
const findOneExample = tools.find(
|
||||
(t) =>
|
||||
t.operation === 'find_one' &&
|
||||
t.objectName === findManyExample?.objectName,
|
||||
);
|
||||
const examplePart =
|
||||
findManyExample && findOneExample
|
||||
? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
|
||||
: '';
|
||||
|
||||
lines.push(
|
||||
`\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const tool of standaloneTools) {
|
||||
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
|
||||
|
||||
lines.push(`- \`${tool.name}\`${status}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
export const buildToolCatalogSection = (
|
||||
toolCatalog: ToolIndexEntry[],
|
||||
preloadedTools: string[],
|
||||
): string => {
|
||||
const preloadedSet = new Set(preloadedTools);
|
||||
|
||||
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
|
||||
|
||||
for (const tool of toolCatalog) {
|
||||
const category = tool.category;
|
||||
const existing = toolsByCategory.get(category) ?? [];
|
||||
|
||||
existing.push(tool);
|
||||
toolsByCategory.set(category, existing);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
const preloadedList =
|
||||
preloadedTools.length > 0
|
||||
? preloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n')
|
||||
: '(none)';
|
||||
|
||||
sections.push(`
|
||||
## Available Tools
|
||||
|
||||
You have access to ${toolCatalog.length} tools. Some are pre-loaded and ready to use immediately.
|
||||
To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it.
|
||||
|
||||
### Pre-loaded Tools (ready to use now)
|
||||
${preloadedList}
|
||||
|
||||
### Tool Catalog by Category`);
|
||||
|
||||
const categoryOrder = Object.values(ToolCategory);
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
const tools = toolsByCategory.get(category);
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryLabel = getCategoryLabel(category);
|
||||
|
||||
if (category === ToolCategory.DATABASE_CRUD) {
|
||||
sections.push(
|
||||
buildDatabaseCrudCatalogSection(tools, preloadedSet, categoryLabel),
|
||||
);
|
||||
} else {
|
||||
sections.push(`
|
||||
#### ${categoryLabel} (${tools.length} tools)
|
||||
${tools
|
||||
.map((tool) => {
|
||||
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
|
||||
|
||||
return `- \`${tool.name}\`${status}`;
|
||||
})
|
||||
.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(`
|
||||
### How to Use Tools
|
||||
1. **Pre-loaded tools** (marked with ✓): Use directly
|
||||
2. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`);
|
||||
|
||||
return sections.join('\n');
|
||||
};
|
||||
+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) {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export type AgentToolLoadingStrategy = 'preload' | 'lazy';
|
||||
+5
-174
@@ -1,19 +1,12 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
assertUnreachable,
|
||||
getValidTimeZoneOrUndefined,
|
||||
} from 'twenty-shared/utils';
|
||||
import { getValidTimeZoneOrUndefined } from 'twenty-shared/utils';
|
||||
|
||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
||||
import { ToolCategory } from 'twenty-shared/ai';
|
||||
import { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||
import {
|
||||
EXECUTE_TOOL_TOOL_NAME,
|
||||
LEARN_TOOLS_TOOL_NAME,
|
||||
LOAD_SKILL_TOOL_NAME,
|
||||
} from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools';
|
||||
import { type ToolIndexEntry } from 'src/engine/core-modules/tool-provider/types/tool-index-entry.type';
|
||||
import { buildToolCatalogSection } from 'src/engine/core-modules/tool-provider/utils/build-tool-catalog-section.util';
|
||||
import {
|
||||
AgentActorContextService,
|
||||
type UserContext,
|
||||
@@ -103,7 +96,7 @@ export class SystemPromptBuilderService {
|
||||
});
|
||||
}
|
||||
|
||||
const toolSection = this.buildToolCatalogSection(
|
||||
const toolSection = buildToolCatalogSection(
|
||||
toolCatalog,
|
||||
COMMON_PRELOAD_TOOLS,
|
||||
);
|
||||
@@ -160,7 +153,7 @@ export class SystemPromptBuilderService {
|
||||
parts.push(this.buildUserContextSection(userContext));
|
||||
}
|
||||
|
||||
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
|
||||
parts.push(buildToolCatalogSection(toolCatalog, preloadedTools));
|
||||
|
||||
const skillSection = this.buildSkillCatalogSection(skillCatalog);
|
||||
|
||||
@@ -257,166 +250,4 @@ To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s).
|
||||
|
||||
${skillsList}`;
|
||||
}
|
||||
|
||||
buildToolCatalogSection(
|
||||
toolCatalog: ToolIndexEntry[],
|
||||
preloadedTools: string[],
|
||||
): string {
|
||||
const preloadedSet = new Set(preloadedTools);
|
||||
|
||||
const toolsByCategory = new Map<string, ToolIndexEntry[]>();
|
||||
|
||||
for (const tool of toolCatalog) {
|
||||
const category = tool.category;
|
||||
const existing = toolsByCategory.get(category) ?? [];
|
||||
|
||||
existing.push(tool);
|
||||
toolsByCategory.set(category, existing);
|
||||
}
|
||||
|
||||
const sections: string[] = [];
|
||||
|
||||
const preloadedList =
|
||||
preloadedTools.length > 0
|
||||
? preloadedTools.map((toolName) => `- \`${toolName}\` ✓`).join('\n')
|
||||
: '(none)';
|
||||
|
||||
sections.push(`
|
||||
## Available Tools
|
||||
|
||||
You have access to ${toolCatalog.length} tools. Some are pre-loaded and ready to use immediately.
|
||||
To use any other tool, first call \`${LEARN_TOOLS_TOOL_NAME}\` to learn its schema, then call \`${EXECUTE_TOOL_TOOL_NAME}\` to run it.
|
||||
|
||||
### Pre-loaded Tools (ready to use now)
|
||||
${preloadedList}
|
||||
|
||||
### Tool Catalog by Category`);
|
||||
|
||||
const categoryOrder = Object.values(ToolCategory);
|
||||
|
||||
for (const category of categoryOrder) {
|
||||
const tools = toolsByCategory.get(category);
|
||||
|
||||
if (!tools || tools.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const categoryLabel = this.getCategoryLabel(category);
|
||||
|
||||
if (category === ToolCategory.DATABASE_CRUD) {
|
||||
sections.push(
|
||||
this.buildDatabaseCrudCatalogSection(
|
||||
tools,
|
||||
preloadedSet,
|
||||
categoryLabel,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
sections.push(`
|
||||
#### ${categoryLabel} (${tools.length} tools)
|
||||
${tools
|
||||
.map((tool) => {
|
||||
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
|
||||
|
||||
return `- \`${tool.name}\`${status}`;
|
||||
})
|
||||
.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(`
|
||||
### How to Use Tools
|
||||
1. **Pre-loaded tools** (marked with ✓): Use directly
|
||||
2. **Other tools**: First call \`${LEARN_TOOLS_TOOL_NAME}({toolNames: ["tool_name"]})\` to learn the schema, then call \`${EXECUTE_TOOL_TOOL_NAME}({toolName: "tool_name", arguments: {...}})\` to run it`);
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
private buildDatabaseCrudCatalogSection(
|
||||
tools: ToolIndexEntry[],
|
||||
preloadedSet: Set<string>,
|
||||
categoryLabel: string,
|
||||
): string {
|
||||
const operationOrder: string[] = [];
|
||||
const seenOps = new Set<string>();
|
||||
|
||||
const objectToolsMap = new Map<string, string[]>();
|
||||
const standaloneTools: ToolIndexEntry[] = [];
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool.objectName && tool.operation) {
|
||||
const ops = objectToolsMap.get(tool.objectName) ?? [];
|
||||
|
||||
ops.push(tool.operation);
|
||||
objectToolsMap.set(tool.objectName, ops);
|
||||
|
||||
if (!seenOps.has(tool.operation)) {
|
||||
seenOps.add(tool.operation);
|
||||
operationOrder.push(tool.operation);
|
||||
}
|
||||
} else {
|
||||
standaloneTools.push(tool);
|
||||
}
|
||||
}
|
||||
|
||||
const lines: string[] = [`\n#### ${categoryLabel} (${tools.length} tools)`];
|
||||
|
||||
if (objectToolsMap.size > 0) {
|
||||
const objectNames = [...objectToolsMap.keys()].sort();
|
||||
|
||||
lines.push(`Operations per object:`);
|
||||
lines.push(...operationOrder.map((op) => `- \`${op}_{object}\``));
|
||||
|
||||
lines.push(`\nObjects (${objectNames.length}):`);
|
||||
lines.push(...objectNames.map((name) => `- \`${name}\``));
|
||||
|
||||
const findManyExample = tools.find((t) => t.operation === 'find_many');
|
||||
const findOneExample = tools.find(
|
||||
(t) =>
|
||||
t.operation === 'find_one' &&
|
||||
t.objectName === findManyExample?.objectName,
|
||||
);
|
||||
const examplePart =
|
||||
findManyExample && findOneExample
|
||||
? ` e.g. \`${findManyExample.name}\` / \`${findOneExample.name}\``
|
||||
: '';
|
||||
|
||||
lines.push(
|
||||
`\nTool name = operation + object name. *_many_* operations use the plural form, *_one_* use the singular form.${examplePart}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const tool of standaloneTools) {
|
||||
const status = preloadedSet.has(tool.name) ? ' ✓' : '';
|
||||
|
||||
lines.push(`- \`${tool.name}\`${status}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
private getCategoryLabel(category: ToolCategory): string {
|
||||
switch (category) {
|
||||
case ToolCategory.DATABASE_CRUD:
|
||||
return 'Database Tools (CRUD operations)';
|
||||
case ToolCategory.ACTION:
|
||||
return 'Action Tools (HTTP, Email, etc.)';
|
||||
case ToolCategory.WORKFLOW:
|
||||
return 'Workflow Tools (create/manage workflows)';
|
||||
case ToolCategory.METADATA:
|
||||
return 'Metadata Tools (schema management)';
|
||||
case ToolCategory.VIEW:
|
||||
return 'View Tools (manage views, fields, filters, and sorts)';
|
||||
case ToolCategory.DASHBOARD:
|
||||
return 'Dashboard Tools (create/manage dashboards)';
|
||||
case ToolCategory.LOGIC_FUNCTION:
|
||||
return 'Logic Functions (custom tools)';
|
||||
case ToolCategory.NAVIGATION_MENU_ITEM:
|
||||
return 'Navigation Menu Item Tools (sidebar entries, folders, and user favorites)';
|
||||
case ToolCategory.WEBHOOK:
|
||||
return 'Webhook Tools (outgoing webhooks)';
|
||||
default:
|
||||
return assertUnreachable(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user