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,
|
} as McpAnnotatedTool,
|
||||||
[EXECUTE_TOOL_TOOL_NAME]: {
|
[EXECUTE_TOOL_TOOL_NAME]: {
|
||||||
...createExecuteToolTool(this.toolRegistry, toolContext, {
|
...createExecuteToolTool(this.toolRegistry, toolContext, {
|
||||||
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
|
isToolAllowed: (toolName) => !MCP_EXCLUDED_TOOL_NAMES.has(toolName),
|
||||||
}),
|
}),
|
||||||
inputSchema: executeToolInputSchema,
|
inputSchema: executeToolInputSchema,
|
||||||
annotations: MCP_EXECUTE_TOOL_ANNOTATIONS,
|
annotations: MCP_EXECUTE_TOOL_ANNOTATIONS,
|
||||||
@@ -269,7 +269,7 @@ export class McpProtocolService {
|
|||||||
} as McpAnnotatedTool,
|
} as McpAnnotatedTool,
|
||||||
[LEARN_TOOLS_TOOL_NAME]: {
|
[LEARN_TOOLS_TOOL_NAME]: {
|
||||||
...createLearnToolsTool(this.toolRegistry, toolContext, {
|
...createLearnToolsTool(this.toolRegistry, toolContext, {
|
||||||
excludeTools: MCP_EXCLUDED_TOOL_NAMES,
|
isToolAllowed: (toolName) => !MCP_EXCLUDED_TOOL_NAMES.has(toolName),
|
||||||
}),
|
}),
|
||||||
inputSchema: zodSchema(learnToolsInputSchema),
|
inputSchema: zodSchema(learnToolsInputSchema),
|
||||||
annotations: MCP_CLOSED_WORLD_READ_ONLY_TOOL_ANNOTATIONS,
|
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 suggestSimilarToolNames = jest.fn();
|
||||||
const toolRegistry = {
|
const toolRegistry = {
|
||||||
getToolInfo: jest.fn().mockResolvedValue([]),
|
getToolInfo: jest.fn().mockResolvedValue([]),
|
||||||
@@ -84,7 +84,7 @@ describe('createLearnToolsTool', () => {
|
|||||||
} as unknown as ToolRegistryService;
|
} as unknown as ToolRegistryService;
|
||||||
|
|
||||||
const learnTools = createLearnToolsTool(toolRegistry, context, {
|
const learnTools = createLearnToolsTool(toolRegistry, context, {
|
||||||
excludeTools: new Set(['code_interpreter']),
|
isToolAllowed: (toolName) => toolName !== 'code_interpreter',
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await learnTools.execute({
|
const result = await learnTools.execute({
|
||||||
@@ -101,6 +101,31 @@ describe('createLearnToolsTool', () => {
|
|||||||
expect(result.message).toBe('No matching tools found.');
|
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 () => {
|
it('does not consult the spill service when spillLargeOutput is not set', async () => {
|
||||||
const spillToolOutputIfTooLarge = jest.fn();
|
const spillToolOutputIfTooLarge = jest.fn();
|
||||||
const toolRegistry = {
|
const toolRegistry = {
|
||||||
|
|||||||
+2
-2
@@ -46,7 +46,7 @@ export const createExecuteToolTool = (
|
|||||||
toolRegistry: ToolRegistryService,
|
toolRegistry: ToolRegistryService,
|
||||||
context: ToolContext,
|
context: ToolContext,
|
||||||
options?: {
|
options?: {
|
||||||
excludeTools?: Set<string>;
|
isToolAllowed?: (toolName: string) => boolean;
|
||||||
compactOutput?: boolean;
|
compactOutput?: boolean;
|
||||||
spillLargeOutput?: boolean;
|
spillLargeOutput?: boolean;
|
||||||
},
|
},
|
||||||
@@ -57,7 +57,7 @@ export const createExecuteToolTool = (
|
|||||||
execute: async (parameters: ExecuteToolInput): Promise<ToolOutput> => {
|
execute: async (parameters: ExecuteToolInput): Promise<ToolOutput> => {
|
||||||
const { toolName, arguments: args = {} } = parameters;
|
const { toolName, arguments: args = {} } = parameters;
|
||||||
|
|
||||||
if (options?.excludeTools?.has(toolName)) {
|
if (options?.isToolAllowed?.(toolName) === false) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: `Tool "${toolName}" is not available`,
|
message: `Tool "${toolName}" is not available`,
|
||||||
|
|||||||
+4
-4
@@ -44,7 +44,7 @@ export type LearnToolsResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type LearnToolsOptions = {
|
export type LearnToolsOptions = {
|
||||||
excludeTools?: Set<string>;
|
isToolAllowed?: (toolName: string) => boolean;
|
||||||
spillLargeOutput?: boolean;
|
spillLargeOutput?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -59,9 +59,9 @@ export const createLearnToolsTool = (
|
|||||||
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
|
execute: async (parameters: LearnToolsInput): Promise<LearnToolsResult> => {
|
||||||
const { toolNames, aspects } = parameters;
|
const { toolNames, aspects } = parameters;
|
||||||
|
|
||||||
const excludeTools = options?.excludeTools;
|
const { isToolAllowed } = options ?? {};
|
||||||
const allowedNames = excludeTools
|
const allowedNames = isToolAllowed
|
||||||
? toolNames.filter((name) => !excludeTools.has(name))
|
? toolNames.filter((name) => isToolAllowed(name))
|
||||||
: toolNames;
|
: toolNames;
|
||||||
|
|
||||||
const toolInfos = await toolRegistry.getToolInfo(
|
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 { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { generateText } from 'ai';
|
import { generateText } from 'ai';
|
||||||
|
import { ToolCategory } from 'twenty-shared/ai';
|
||||||
|
|
||||||
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
import { BillingUsageService } from 'src/engine/core-modules/billing/services/billing-usage.service';
|
||||||
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.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', () => {
|
describe('AgentAsyncExecutorService — workflow agent role-scoped tool resolution', () => {
|
||||||
let service: AgentAsyncExecutorService;
|
let service: AgentAsyncExecutorService;
|
||||||
let toolRegistry: { getToolsByCategories: jest.Mock };
|
let toolRegistry: {
|
||||||
|
getToolsByCategories: jest.Mock;
|
||||||
|
buildToolIndex: jest.Mock;
|
||||||
|
};
|
||||||
let roleTargetRepository: { findOne: jest.Mock };
|
let roleTargetRepository: { findOne: jest.Mock };
|
||||||
let aiBillingService: {
|
let aiBillingService: {
|
||||||
decrementAndCheckAvailableCredits: jest.Mock;
|
decrementAndCheckAvailableCredits: jest.Mock;
|
||||||
@@ -79,7 +83,10 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
toolRegistry = { getToolsByCategories: jest.fn().mockResolvedValue({}) };
|
toolRegistry = {
|
||||||
|
getToolsByCategories: jest.fn().mockResolvedValue({}),
|
||||||
|
buildToolIndex: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
roleTargetRepository = { findOne: jest.fn() };
|
roleTargetRepository = { findOne: jest.fn() };
|
||||||
aiBillingService = {
|
aiBillingService = {
|
||||||
decrementAndCheckAvailableCredits: jest
|
decrementAndCheckAvailableCredits: jest
|
||||||
@@ -146,7 +153,7 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
|
|||||||
service = module.get<AgentAsyncExecutorService>(AgentAsyncExecutorService);
|
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 });
|
roleTargetRepository.findOne.mockResolvedValueOnce({ roleId: agentRoleId });
|
||||||
|
|
||||||
await service.executeAgent({
|
await service.executeAgent({
|
||||||
@@ -161,10 +168,47 @@ describe('AgentAsyncExecutorService — workflow agent role-scoped tool resoluti
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
roleId: agentRoleId,
|
roleId: agentRoleId,
|
||||||
rolePermissionConfig: { intersectionOf: [agentRoleId] },
|
rolePermissionConfig: { intersectionOf: [agentRoleId] },
|
||||||
|
requireExplicitObjectGrants: true,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
}),
|
}),
|
||||||
expect.objectContaining({ wrapWithErrorContext: false }),
|
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 () => {
|
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 { 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 { 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 { 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 { 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 { 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';
|
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 { 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 { 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 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 { 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 { 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';
|
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
|
||||||
@@ -56,7 +65,6 @@ import {
|
|||||||
AiExceptionCode,
|
AiExceptionCode,
|
||||||
} from 'src/engine/metadata-modules/ai/ai.exception';
|
} from 'src/engine/metadata-modules/ai/ai.exception';
|
||||||
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
|
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 { 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';
|
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||||
|
|
||||||
@@ -111,6 +119,117 @@ export class AgentAsyncExecutorService {
|
|||||||
return roleTarget?.roleId;
|
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({
|
async executeAgent({
|
||||||
agent,
|
agent,
|
||||||
userPrompt,
|
userPrompt,
|
||||||
@@ -120,6 +239,7 @@ export class AgentAsyncExecutorService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
userWorkspaceId,
|
userWorkspaceId,
|
||||||
operationType = UsageOperationType.AI_WORKFLOW_TOKEN,
|
operationType = UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||||
|
toolLoadingStrategy = 'preload',
|
||||||
}: {
|
}: {
|
||||||
agent: AgentEntity | null;
|
agent: AgentEntity | null;
|
||||||
userPrompt: string;
|
userPrompt: string;
|
||||||
@@ -129,6 +249,7 @@ export class AgentAsyncExecutorService {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
userWorkspaceId?: string | null;
|
userWorkspaceId?: string | null;
|
||||||
operationType?: UsageOperationType;
|
operationType?: UsageOperationType;
|
||||||
|
toolLoadingStrategy?: AgentToolLoadingStrategy;
|
||||||
}): Promise<AgentExecutionResult> {
|
}): Promise<AgentExecutionResult> {
|
||||||
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
|
await this.billingUsageService.hasAvailableCreditsOrThrow(workspaceId);
|
||||||
|
|
||||||
@@ -155,6 +276,7 @@ export class AgentAsyncExecutorService {
|
|||||||
await this.aiModelRegistryService.resolveModelForAgent(agent);
|
await this.aiModelRegistryService.resolveModelForAgent(agent);
|
||||||
|
|
||||||
let tools: ToolSet = {};
|
let tools: ToolSet = {};
|
||||||
|
let toolCatalogSection = '';
|
||||||
let providerOptions = getCallLevelProviderOptions({
|
let providerOptions = getCallLevelProviderOptions({
|
||||||
sdkPackage: registeredModel.sdkPackage,
|
sdkPackage: registeredModel.sdkPackage,
|
||||||
providerOptions: undefined,
|
providerOptions: undefined,
|
||||||
@@ -175,38 +297,27 @@ export class AgentAsyncExecutorService {
|
|||||||
|
|
||||||
let registryTools: ToolSet = {};
|
let registryTools: ToolSet = {};
|
||||||
|
|
||||||
// Workflow agent registry tools are scoped exclusively by the agent
|
// Registry tools are scoped exclusively by the agent permission-tab
|
||||||
// permission-tab role. No role means no registry tools.
|
// role. No role means no registry tools.
|
||||||
if (isDefined(agentRoleId)) {
|
if (isDefined(agentRoleId)) {
|
||||||
const agentRolePermissionConfig: RolePermissionConfig = {
|
if (toolLoadingStrategy === 'lazy') {
|
||||||
intersectionOf: [agentRoleId],
|
const lazyToolset = await this.buildLazyRegistryTools({
|
||||||
};
|
agent,
|
||||||
|
agentRoleId,
|
||||||
|
authContext,
|
||||||
|
actorContext,
|
||||||
|
});
|
||||||
|
|
||||||
const toolProviderContext: ToolProviderContext = {
|
registryTools = lazyToolset.tools;
|
||||||
workspaceId: agent.workspaceId,
|
toolCatalogSection = lazyToolset.catalogSection;
|
||||||
roleId: agentRoleId,
|
} else {
|
||||||
rolePermissionConfig: agentRolePermissionConfig,
|
registryTools = await this.buildPreloadedRegistryTools({
|
||||||
requireExplicitObjectGrants: true,
|
agent,
|
||||||
authContext,
|
agentRoleId,
|
||||||
actorContext,
|
authContext,
|
||||||
userId:
|
actorContext,
|
||||||
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,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const nativeTools = this.nativeToolBinder.bind(
|
const nativeTools = this.nativeToolBinder.bind(
|
||||||
@@ -234,7 +345,7 @@ export class AgentAsyncExecutorService {
|
|||||||
let hasNoMoreAvailableCredits = false;
|
let hasNoMoreAvailableCredits = false;
|
||||||
|
|
||||||
const textResponse = await generateText({
|
const textResponse = await generateText({
|
||||||
system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}`,
|
system: `${baseSystemPrompt}\n\n${agent ? agent.prompt : ''}${toolCatalogSection}`,
|
||||||
tools,
|
tools,
|
||||||
model: registeredModel.model,
|
model: registeredModel.model,
|
||||||
prompt: userPrompt,
|
prompt: userPrompt,
|
||||||
|
|||||||
+1
@@ -73,6 +73,7 @@ export class AgentRunService {
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
userWorkspaceId: requestUserWorkspaceId,
|
userWorkspaceId: requestUserWorkspaceId,
|
||||||
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
|
operationType: UsageOperationType.AI_WORKFLOW_TOKEN,
|
||||||
|
toolLoadingStrategy: 'lazy',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNoMoreAvailableCredits) {
|
if (hasNoMoreAvailableCredits) {
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
export type AgentToolLoadingStrategy = 'preload' | 'lazy';
|
||||||
+5
-174
@@ -1,19 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import { getValidTimeZoneOrUndefined } from 'twenty-shared/utils';
|
||||||
assertUnreachable,
|
|
||||||
getValidTimeZoneOrUndefined,
|
|
||||||
} from 'twenty-shared/utils';
|
|
||||||
|
|
||||||
import { COMMON_PRELOAD_TOOLS } from 'src/engine/core-modules/tool-provider/constants/common-preload-tools.const';
|
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 { ToolRegistryService } from 'src/engine/core-modules/tool-provider/services/tool-registry.service';
|
||||||
import {
|
import { LOAD_SKILL_TOOL_NAME } from 'src/engine/core-modules/tool-provider/tools';
|
||||||
EXECUTE_TOOL_TOOL_NAME,
|
|
||||||
LEARN_TOOLS_TOOL_NAME,
|
|
||||||
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 { 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 {
|
import {
|
||||||
AgentActorContextService,
|
AgentActorContextService,
|
||||||
type UserContext,
|
type UserContext,
|
||||||
@@ -103,7 +96,7 @@ export class SystemPromptBuilderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolSection = this.buildToolCatalogSection(
|
const toolSection = buildToolCatalogSection(
|
||||||
toolCatalog,
|
toolCatalog,
|
||||||
COMMON_PRELOAD_TOOLS,
|
COMMON_PRELOAD_TOOLS,
|
||||||
);
|
);
|
||||||
@@ -160,7 +153,7 @@ export class SystemPromptBuilderService {
|
|||||||
parts.push(this.buildUserContextSection(userContext));
|
parts.push(this.buildUserContextSection(userContext));
|
||||||
}
|
}
|
||||||
|
|
||||||
parts.push(this.buildToolCatalogSection(toolCatalog, preloadedTools));
|
parts.push(buildToolCatalogSection(toolCatalog, preloadedTools));
|
||||||
|
|
||||||
const skillSection = this.buildSkillCatalogSection(skillCatalog);
|
const skillSection = this.buildSkillCatalogSection(skillCatalog);
|
||||||
|
|
||||||
@@ -257,166 +250,4 @@ To load a skill, call \`${LOAD_SKILL_TOOL_NAME}\` with the skill name(s).
|
|||||||
|
|
||||||
${skillsList}`;
|
${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