refactor(core): Remove node tools searcher module (#32876)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
bjorger
2026-06-24 13:19:52 +00:00
committed by GitHub
co-authored by Cursor
parent c8ef7b8089
commit fa1dd61203
17 changed files with 36 additions and 578 deletions
@@ -70,7 +70,6 @@ describe('sanitizeAgentJsonConfig', () => {
config: {
toolCallConcurrency: 2,
webSearch: { enabled: true, provider: 'native' },
nodeTools: { enabled: true },
},
});
expect(AgentJsonConfigSchema.safeParse(sanitized).success).toBe(true);
@@ -300,11 +300,6 @@ export const AgentJsonConfigSchema = z.object({
.describe(
'Maximum number of agent loop iterations per run. Do not set unless the user explicitly asks.',
),
nodeTools: z
.object({
enabled: z.boolean(),
})
.optional(),
})
.optional(),
});
@@ -358,7 +353,3 @@ export function formatZodErrors(error: ZodError): ConfigValidationError[] {
received: 'received' in issue ? String(issue.received) : undefined,
}));
}
export function isNodeToolsEnabled(config: AgentJsonConfig['config']): boolean {
return config?.nodeTools?.enabled === true;
}
@@ -8,7 +8,7 @@ import { Config, Env } from '../decorators';
* `N8N_AGENTS_MODULES`. The backend fails fast on unknown tokens so typos
* surface at startup instead of silently disabling a feature.
*/
export const AGENTS_MODULE_NAMES = ['node-tools-searcher'] as const;
export const AGENTS_MODULE_NAMES = [] as const;
export type AgentsModuleName = (typeof AGENTS_MODULE_NAMES)[number];
@@ -17,9 +17,15 @@ class AgentsModuleArray extends CommaSeparatedStringArray<AgentsModuleName> {
super(str);
for (const name of this) {
const moduleName: string = name;
if (!AGENTS_MODULE_NAMES.includes(name)) {
const validTokens = AGENTS_MODULE_NAMES.join(', ');
throw new Error(
`Unknown agents module: "${name}". Valid tokens: ${AGENTS_MODULE_NAMES.join(', ')}.`,
`Unknown agents module: "${moduleName}". ${
validTokens
? `Valid tokens: ${validTokens}.`
: 'No agents modules are currently supported.'
}`,
);
}
}
@@ -35,12 +41,7 @@ export class AgentsConfig {
/**
* Comma-separated list of agent sub-feature modules to enable. Each entry
* gates a specific frontend/runtime capability inside the agents module.
* Currently known:
* - `node-tools-searcher` — surfaces the "Built-in node tools" toggle in
* the agent editor.
*
* Gates the UI surface only — existing agents persisted with a given
* capability turned on continue to run even if its token is removed here.
* Add supported module tokens to `AGENTS_MODULE_NAMES`.
*/
@Env('N8N_AGENTS_MODULES')
modules: AgentsModuleArray = [];
@@ -1,6 +1,5 @@
import type { AgentJsonConfig } from '@n8n/api-types';
import { mockLogger } from '@n8n/backend-test-utils';
import type { AgentsConfig } from '@n8n/config';
import { mock } from 'jest-mock-extended';
import type { CredentialsService } from '@/credentials/credentials.service';
@@ -37,13 +36,12 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
} as unknown as Agent;
}
function makeService(config: Partial<AgentsConfig> = {}) {
function makeService() {
const agentRepository = mock<AgentRepository>();
const agentTaskRepository = mock<AgentTaskRepository>();
const agentSkillsService = mock<AgentSkillsService>();
const runtimeCacheService = mock<AgentRuntimeCacheService>();
const credentialsService = mock<CredentialsService>();
const agentsConfig = { modules: [], ...config } as AgentsConfig;
agentRepository.save.mockImplementation(async (agent) => agent as Agent);
credentialsService.findAllCredentialIdsForProject.mockResolvedValue([]);
@@ -61,7 +59,6 @@ function makeService(config: Partial<AgentsConfig> = {}) {
agentRepository,
agentTaskRepository,
agentSkillsService,
agentsConfig,
runtimeCacheService,
credentialsService,
);
@@ -112,27 +109,6 @@ describe('AgentConfigService', () => {
});
});
it('gates config.nodeTools.enabled on the node-tools module', async () => {
await expect(
makeService().service.validateConfig({
...baseConfig,
config: { nodeTools: { enabled: true } },
}),
).resolves.toMatchObject({
valid: false,
error: expect.stringContaining('node-tools-searcher'),
});
await expect(
makeService({
modules: ['node-tools-searcher'] as AgentsConfig['modules'],
}).service.validateConfig({
...baseConfig,
config: { nodeTools: { enabled: true } },
}),
).resolves.toMatchObject({ valid: true });
});
it('accepts draft credentials that are not checked until update sanitization', async () => {
const { service } = makeService();
@@ -1,4 +1,4 @@
import { AgentJsonConfigSchema, isNodeToolsEnabled, type AgentJsonConfig } from '@n8n/api-types';
import { AgentJsonConfigSchema, type AgentJsonConfig } from '@n8n/api-types';
const baseConfig: AgentJsonConfig = {
name: 'Test Agent',
@@ -6,43 +6,6 @@ const baseConfig: AgentJsonConfig = {
instructions: 'Be helpful',
};
describe('AgentJsonConfigSchema — config.nodeTools', () => {
it('accepts a config without nodeTools', () => {
expect(AgentJsonConfigSchema.safeParse({ ...baseConfig, config: {} }).success).toBe(true);
});
it('accepts nodeTools: { enabled: true }', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
config: { nodeTools: { enabled: true } },
});
expect(parsed.success).toBe(true);
});
it('accepts nodeTools: { enabled: false }', () => {
const parsed = AgentJsonConfigSchema.safeParse({
...baseConfig,
config: { nodeTools: { enabled: false } },
});
expect(parsed.success).toBe(true);
});
it('rejects nodeTools without enabled', () => {
expect(
AgentJsonConfigSchema.safeParse({ ...baseConfig, config: { nodeTools: {} } }).success,
).toBe(false);
});
it('rejects nodeTools.enabled of the wrong type', () => {
expect(
AgentJsonConfigSchema.safeParse({
...baseConfig,
config: { nodeTools: { enabled: 'yes' } },
}).success,
).toBe(false);
});
});
describe('AgentJsonConfigSchema — skill refs', () => {
it('accepts a skill ref with a valid id', () => {
const parsed = AgentJsonConfigSchema.safeParse({
@@ -72,24 +35,6 @@ describe('AgentJsonConfigSchema — skill refs', () => {
});
});
describe('isNodeToolsEnabled', () => {
it('returns false when config is undefined', () => {
expect(isNodeToolsEnabled(undefined)).toBe(false);
});
it('returns false when config has no nodeTools field', () => {
expect(isNodeToolsEnabled({})).toBe(false);
});
it('returns false when nodeTools.enabled is false', () => {
expect(isNodeToolsEnabled({ nodeTools: { enabled: false } })).toBe(false);
});
it('returns true only when nodeTools.enabled is explicitly true', () => {
expect(isNodeToolsEnabled({ nodeTools: { enabled: true } })).toBe(true);
});
});
describe('AgentJsonConfigSchema — subAgents', () => {
it('accepts saved agent references', () => {
const parsed = AgentJsonConfigSchema.safeParse({
@@ -43,7 +43,6 @@ import type { AgentTaskService } from '../agent-task.service';
import { AgentsService } from '../agents.service';
import { AgentTestChatService } from '../agent-test-chat.service';
import { AgentValidationService } from '../agent-validation.service';
import type { AgentsToolsService } from '../agents-tools.service';
import type { AgentHistory } from '../entities/agent-history.entity';
import type { AgentTaskSnapshot } from '../entities/agent-task-snapshot.entity';
import type { Agent } from '../entities/agent.entity';
@@ -118,7 +117,6 @@ function makeRuntimeReconstructionService(
mock<N8NCheckpointStorage>(),
mock<AgentSecureRuntime>(),
mock<EphemeralNodeExecutor>(),
mock<AgentsToolsService>(),
mock<N8nMemory>(),
mock<OauthService>(),
{ modules } as unknown as AgentsConfig,
@@ -182,7 +180,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
let chatIntegrationService: jest.Mocked<ChatIntegrationService>;
let agentKnowledgeService: jest.Mocked<AgentKnowledgeService>;
let publisher: jest.Mocked<Publisher>;
let agentsConfig: AgentsConfig;
let globalConfig: jest.Mocked<GlobalConfig>;
let telemetry: jest.Mocked<Telemetry>;
let runtimeCacheService: AgentRuntimeCacheService;
@@ -214,11 +211,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentKnowledgeService = mock<AgentKnowledgeService>();
publisher = mock<Publisher>();
publisher.publishCommand.mockResolvedValue();
agentsConfig = {
modules: [],
sandboxEnabled: false,
sandboxProvider: '',
} as unknown as AgentsConfig;
globalConfig = mock<GlobalConfig>({
multiMainSetup: { enabled: false },
} as Partial<GlobalConfig>);
@@ -245,7 +237,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
agentRepository,
agentTaskRepository,
agentSkillsService,
agentsConfig,
runtimeCacheService,
credentialsService,
);
@@ -349,7 +340,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
userId: string;
runtimeProfile: 'top-level';
config: AgentJsonConfig;
nodeToolsEnabled: boolean;
subAgentDelegation: {
sourcesById: Record<string, never>;
availableSubAgents: [];
@@ -370,7 +360,6 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
model: 'anthropic/claude-sonnet-4-5',
instructions: 'Be helpful',
},
nodeToolsEnabled: false,
parentAgentIdForDelegation: agentId,
subAgentDelegation: {
sourcesById: {},
@@ -45,7 +45,6 @@ describe('agent telemetry', () => {
},
config: {
webSearch: { enabled: true },
nodeTools: { enabled: true },
},
memory: {
enabled: true,
@@ -61,8 +60,8 @@ describe('agent telemetry', () => {
).toEqual({
model: 'anthropic/claude-sonnet-4-5',
channels: ['linear', 'slack'],
tool_types: ['custom', 'mcp', 'node_tools', 'provider', 'subagent', 'web_search', 'workflow'],
tool_count: 7,
tool_types: ['custom', 'mcp', 'provider', 'subagent', 'web_search', 'workflow'],
tool_count: 6,
num_skills: 1,
memory_type: 'n8n_observational_episodic',
});
@@ -33,7 +33,6 @@ import type { WorkflowFinderService } from '@/workflows/workflow-finder.service'
import { AgentRuntimeReconstructionService } from '../agent-runtime-reconstruction.service';
import type { AgentKnowledgeSandboxService } from '../agent-knowledge-sandbox.service';
import type { AgentsToolsService } from '../agents-tools.service';
import type { Agent } from '../entities/agent.entity';
import { ChatIntegrationRegistry } from '../integrations/agent-chat-integration';
import { ChatIntegrationActionExecutor } from '../integrations/integration-action-executor';
@@ -85,7 +84,6 @@ function getInjectedToolNames(): string[] {
}
function makeReconstructionService(
agentsToolsService: AgentsToolsService,
modules: string[] = [],
overrides: {
logger?: Logger;
@@ -111,7 +109,6 @@ function makeReconstructionService(
overrides.n8nCheckpointStorage ?? mock<N8NCheckpointStorage>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
agentsToolsService,
mock<N8nMemory>(),
mock<OauthService>(),
{
@@ -144,79 +141,6 @@ function makeAgentEntity(
} as unknown as Agent;
}
describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — node tools gating', () => {
beforeEach(() => {
jest.clearAllMocks();
builtAgent.hasCheckpointStorage.mockReturnValue(true);
});
function setup(options: { nodeToolsModuleEnabled?: boolean } = {}) {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(
agentsToolsService,
options.nodeToolsModuleEnabled ? ['node-tools-searcher'] : [],
);
return { service, agentsToolsService, credentialProvider };
}
it.each([
{
name: 'config.nodeTools is absent and the module is disabled',
nodeToolsModuleEnabled: false,
schemaConfig: undefined,
attaches: false,
},
{
name: 'config.nodeTools is absent and the module is enabled',
nodeToolsModuleEnabled: true,
schemaConfig: undefined,
attaches: false,
},
{
name: 'config.nodeTools.enabled is true but the module is disabled',
nodeToolsModuleEnabled: false,
schemaConfig: { nodeTools: { enabled: true } },
attaches: false,
},
{
name: 'config.nodeTools.enabled is true and the module is enabled',
nodeToolsModuleEnabled: true,
schemaConfig: { nodeTools: { enabled: true } },
attaches: true,
},
{
name: 'config.nodeTools.enabled is false and the module is disabled',
nodeToolsModuleEnabled: false,
schemaConfig: { nodeTools: { enabled: false } },
attaches: false,
},
{
name: 'config.nodeTools.enabled is false and the module is enabled',
nodeToolsModuleEnabled: true,
schemaConfig: { nodeTools: { enabled: false } },
attaches: false,
},
])('$name', async ({ nodeToolsModuleEnabled, schemaConfig, attaches }) => {
const { service, agentsToolsService, credentialProvider } = setup({
nodeToolsModuleEnabled,
});
const entity = makeAgentEntity(schemaConfig);
await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1');
if (attaches) {
expect(agentsToolsService.getRuntimeTools).toHaveBeenCalledWith(
credentialProvider,
'project-1',
);
} else {
expect(agentsToolsService.getRuntimeTools).not.toHaveBeenCalled();
}
});
});
describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP wiring', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -233,10 +157,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — MCP w
});
function setup() {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService);
const service = makeReconstructionService();
return { service, credentialProvider };
}
@@ -284,10 +206,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
function setup() {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService);
const service = makeReconstructionService();
return { service, credentialProvider };
}
@@ -356,10 +276,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('uses the shared default maxChildren when config does not override it', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService, []);
const service = makeReconstructionService();
await service.reconstructFromAgentEntity(makeAgentEntity(), credentialProvider, 'user-1');
@@ -369,10 +287,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('uses subAgents.maxChildren over the SDK default', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService, []);
const service = makeReconstructionService();
const entity = makeAgentEntity(undefined, { subAgents: { maxChildren: 2 } });
await service.reconstructFromAgentEntity(entity, credentialProvider, 'user-1');
@@ -383,8 +299,6 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('resolves subAgents.modelsByDifficulty into delegate tool metadata', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockImplementation(async (credentialId: string) => {
if (credentialId === 'low-cred') {
@@ -395,7 +309,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
}
throw new Error(`unexpected credential ${credentialId}`);
});
const service = makeReconstructionService(agentsToolsService, []);
const service = makeReconstructionService();
const entity = makeAgentEntity(undefined, {
subAgents: {
modelsByDifficulty: {
@@ -421,8 +335,6 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('resolves inline child provider tools for the child model provider', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
credentialProvider.resolve.mockImplementation(async (credentialId: string) => {
if (credentialId === 'high-cred') {
@@ -430,7 +342,7 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
}
throw new Error(`unexpected credential ${credentialId}`);
});
const service = makeReconstructionService(agentsToolsService, []);
const service = makeReconstructionService();
const entity = makeAgentEntity(
{ webSearch: { enabled: true } },
{
@@ -457,10 +369,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — sub-a
});
it('omits inlineSubAgentModelsByDifficulty when no difficulty mappings are configured', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService, []);
const service = makeReconstructionService();
await service.reconstructFromAgentEntity(makeAgentEntity(), credentialProvider, 'user-1');
@@ -485,10 +395,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — n8n c
});
function setup() {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService);
const service = makeReconstructionService();
return { service, credentialProvider };
}
@@ -547,10 +455,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromAgentEntity — check
};
const n8nCheckpointStorage = mock<N8NCheckpointStorage>();
n8nCheckpointStorage.getStorage.mockReturnValue(scopedStorage);
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService, [], { n8nCheckpointStorage });
const service = makeReconstructionService([], { n8nCheckpointStorage });
await service.reconstructFromAgentEntity(makeAgentEntity(), credentialProvider, 'user-1');
@@ -567,10 +473,8 @@ describe('AgentRuntimeReconstructionService.reconstructFromResolvedSource — su
});
it('does not inject top-level integration context/action tools', async () => {
const agentsToolsService = mock<AgentsToolsService>();
agentsToolsService.getRuntimeTools.mockReturnValue([] as BuiltTool[]);
const credentialProvider = mock<CredentialProvider>();
const service = makeReconstructionService(agentsToolsService);
const service = makeReconstructionService();
const config: AgentJsonConfig = {
name: 'Child',
@@ -4,22 +4,15 @@ import {
AGENT_BUILDER_AVAILABLE_AI_UTILITY_TOOL_NODE_TYPES,
AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES,
} from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import { validateNodeConfig } from '@n8n/workflow-sdk';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { NodeCatalogService } from '@/node-catalog';
import {
AgentsToolsService,
isAgentToolNodeType,
isExecutableNodeType,
AgentsToolsService,
} from '../agents-tools.service';
jest.mock('@n8n/workflow-sdk', () => ({
validateNodeConfig: jest.fn().mockReturnValue({ valid: true, errors: [] }),
}));
const ctx = {
resumeData: undefined,
suspend: jest.fn().mockResolvedValue(undefined as never),
@@ -34,13 +27,9 @@ function makeService() {
});
nodeCatalogService.getNodeTypes.mockResolvedValue('node-types-string');
const ephemeralNodeExecutor = mock<EphemeralNodeExecutor>();
const service = new AgentsToolsService(nodeCatalogService);
const logger = mock<Logger>();
const service = new AgentsToolsService(logger, nodeCatalogService, ephemeralNodeExecutor);
return { service, nodeCatalogService, ephemeralNodeExecutor, logger };
return { service, nodeCatalogService };
}
function makeCredentialProvider(
@@ -64,21 +53,6 @@ describe('AgentsToolsService', () => {
});
});
describe('getRuntimeTools()', () => {
it('returns all four tools including run_node_tool', () => {
const { service } = makeService();
const names = service
.getRuntimeTools(makeCredentialProvider(), 'project-1')
.map((t) => t.name);
expect(names).toEqual([
'search_nodes',
'get_node_types',
'list_credentials',
'run_node_tool',
]);
});
});
describe('list_credentials handler', () => {
function getListTool(service: AgentsToolsService, provider: CredentialProvider) {
return service.getSharedTools(provider, 'hint').find((t) => t.name === 'list_credentials')!;
@@ -137,7 +111,7 @@ describe('AgentsToolsService', () => {
describe('search_nodes handler', () => {
function getSearchTool(service: AgentsToolsService) {
return service
.getRuntimeTools(makeCredentialProvider(), 'project-1')
.getSharedTools(makeCredentialProvider(), 'hint')
.find((t) => t.name === 'search_nodes')!;
}
@@ -198,7 +172,7 @@ describe('AgentsToolsService', () => {
describe('get_node_types handler', () => {
function getTypesTool(service: AgentsToolsService) {
return service
.getRuntimeTools(makeCredentialProvider(), 'project-1')
.getSharedTools(makeCredentialProvider(), 'hint')
.find((t) => t.name === 'get_node_types')!;
}
@@ -223,132 +197,4 @@ describe('AgentsToolsService', () => {
]);
});
});
describe('run_node_tool handler', () => {
function getRunTool(service: AgentsToolsService) {
return service
.getRuntimeTools(makeCredentialProvider(), 'project-1')
.find((t) => t.name === 'run_node_tool')!;
}
beforeEach(() => {
jest.mocked(validateNodeConfig).mockReturnValue({ valid: true, errors: [] });
});
it('refuses to run trigger nodes', async () => {
const { service, ephemeralNodeExecutor } = makeService();
const result = await getRunTool(service).handler!(
{ nodeType: 'n8n-nodes-base.scheduleTrigger', nodeTypeVersion: 1 },
ctx,
);
expect(ephemeralNodeExecutor.executeInline).not.toHaveBeenCalled();
expect(result).toMatchObject({
status: 'error',
message: expect.stringContaining('scheduleTrigger'),
});
});
it('skips validation and calls executeInline when nodeParameters is absent', async () => {
const { service, ephemeralNodeExecutor } = makeService();
ephemeralNodeExecutor.executeInline.mockResolvedValue({ status: 'success' } as never);
await getRunTool(service).handler!(
{ nodeType: 'n8n-nodes-base.httpRequest', nodeTypeVersion: 4 },
ctx,
);
expect(validateNodeConfig).not.toHaveBeenCalled();
expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalled();
});
it('validates nodeParameters via validateNodeConfig before executing', async () => {
const { service, ephemeralNodeExecutor } = makeService();
ephemeralNodeExecutor.executeInline.mockResolvedValue({ status: 'success' } as never);
await getRunTool(service).handler!(
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeTypeVersion: 4,
nodeParameters: { url: 'https://example.com' },
},
ctx,
);
expect(validateNodeConfig).toHaveBeenCalledWith(
'n8n-nodes-base.httpRequest',
4,
{
parameters: { url: 'https://example.com' },
},
{ isToolNode: true },
);
expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalled();
});
it('returns an error and skips executeInline when validation fails', async () => {
jest.mocked(validateNodeConfig).mockReturnValue({
valid: false,
errors: [{ path: 'method', message: 'Field "method" has invalid value.' }],
});
const { service, ephemeralNodeExecutor } = makeService();
const result = await getRunTool(service).handler!(
{
nodeType: 'n8n-nodes-base.httpRequest',
nodeTypeVersion: 4,
nodeParameters: { method: 'DELETE' },
},
ctx,
);
expect(ephemeralNodeExecutor.executeInline).not.toHaveBeenCalled();
expect(result).toMatchObject({
status: 'error',
message: expect.stringContaining('"method"'),
});
});
it('returns a structured error when executeInline throws', async () => {
const { service, ephemeralNodeExecutor, logger } = makeService();
ephemeralNodeExecutor.executeInline.mockRejectedValue(new Error('boom'));
const result = await getRunTool(service).handler!(
{ nodeType: 'n8n-nodes-base.httpRequest', nodeTypeVersion: 4 },
ctx,
);
expect(result).toMatchObject({
status: 'error',
message: expect.stringContaining('boom'),
});
expect(logger.warn).toHaveBeenCalled();
});
it('maps inputData, passes credentials and projectId through to executeInline', async () => {
const executionResult = { status: 'success', data: [{ json: { ok: true } }] };
const { service, ephemeralNodeExecutor } = makeService();
ephemeralNodeExecutor.executeInline.mockResolvedValue(executionResult as never);
const result = await getRunTool(service).handler!(
{
nodeType: 'n8n-nodes-base.gmail',
nodeTypeVersion: 2,
credentials: { gmailOAuth2: { id: 'cred-1', name: 'My Gmail' } },
inputData: { to: 'user@example.com' },
},
ctx,
);
expect(ephemeralNodeExecutor.executeInline).toHaveBeenCalledWith(
expect.objectContaining({
credentialDetails: { gmailOAuth2: { id: 'cred-1', name: 'My Gmail' } },
inputData: [{ json: { to: 'user@example.com' } }],
projectId: 'project-1',
}),
);
expect(result).toEqual(executionResult);
});
});
});
@@ -1,13 +1,11 @@
import { extractFromAIParameters } from '@n8n/ai-utilities/fromai-helpers';
import {
AgentJsonConfigSchema,
isNodeToolsEnabled,
sanitizeAgentJsonConfig,
type AgentJsonConfig,
type AgentJsonToolConfig,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { AgentsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import { UserError, type INodeParameters } from 'n8n-workflow';
@@ -34,15 +32,10 @@ export class AgentConfigService {
private readonly agentRepository: AgentRepository,
private readonly agentTaskRepository: AgentTaskRepository,
private readonly agentSkillsService: AgentSkillsService,
private readonly agentsConfig: AgentsConfig,
private readonly runtimeCacheService: AgentRuntimeCacheService,
private readonly credentialsService: CredentialsService,
) {}
private isNodeToolsModuleEnabled(): boolean {
return this.agentsConfig.modules.includes('node-tools-searcher');
}
/**
* Get the JSON config for an agent.
*/
@@ -74,14 +67,6 @@ export class AgentConfigService {
const config = parsed.data;
if (isNodeToolsEnabled(config.config) && !this.isNodeToolsModuleEnabled()) {
return {
valid: false,
error:
'config.nodeTools.enabled requires the node-tools-searcher agents module to be enabled.',
};
}
try {
this.validateNodeToolExpressions(config);
} catch (error) {
@@ -7,7 +7,6 @@ import {
ToolDescriptor,
} from '@n8n/agents';
import {
isNodeToolsEnabled,
N8N_CHAT_ACTION_TOOL_NAME,
N8N_CHAT_CONTEXT_TOOL_NAME,
N8N_CHAT_INTEGRATION_TYPE,
@@ -38,7 +37,6 @@ import { createAiMcpFetch, createAiProxyFetch } from '@/utils/ai-proxy-fetch';
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import { AgentsToolsService } from './agents-tools.service';
import { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
import {
@@ -133,7 +131,6 @@ export class AgentRuntimeReconstructionService {
private readonly n8nCheckpointStorage: N8NCheckpointStorage,
private readonly secureRuntime: AgentSecureRuntime,
private readonly ephemeralNodeExecutor: EphemeralNodeExecutor,
private readonly agentsToolsService: AgentsToolsService,
private readonly n8nMemory: N8nMemory,
private readonly oauthService: OauthService,
private readonly agentsConfig: AgentsConfig,
@@ -272,7 +269,6 @@ export class AgentRuntimeReconstructionService {
userId,
runtimeProfile,
config,
nodeToolsEnabled: this.shouldAttachNodeTools(config.config),
subAgentDelegation,
parentAgentIdForDelegation: parentAgentIdForDelegation ?? memoryOwnerAgentId,
integrationType,
@@ -312,14 +308,6 @@ export class AgentRuntimeReconstructionService {
return (_params: AgentJsonMemoryConfig) => this.n8nMemory.getImplementation(agentId);
}
private shouldAttachNodeTools(config: AgentJsonConfig['config']): boolean {
return this.isNodeToolsModuleEnabled() && isNodeToolsEnabled(config);
}
private isNodeToolsModuleEnabled(): boolean {
return this.agentsConfig.modules.includes('node-tools-searcher');
}
private makeToolResolver(projectId: string, userId: string): ToolResolver {
return async (ref: AgentJsonToolConfig) => {
if (ref.type === 'workflow') {
@@ -359,7 +347,6 @@ export class AgentRuntimeReconstructionService {
userId: string;
runtimeProfile: AgentRuntimeProfile;
config: AgentJsonConfig;
nodeToolsEnabled: boolean;
subAgentDelegation: SubAgentDelegationConfig;
parentAgentIdForDelegation: string;
integrationType?: string;
@@ -373,7 +360,6 @@ export class AgentRuntimeReconstructionService {
userId,
runtimeProfile,
config,
nodeToolsEnabled,
subAgentDelegation,
parentAgentIdForDelegation,
integrationType,
@@ -449,10 +435,6 @@ export class AgentRuntimeReconstructionService {
}
}
if (nodeToolsEnabled) {
agent.tool(this.agentsToolsService.getRuntimeTools(credentialProvider, projectId));
}
if (runtimeProfile === 'top-level') {
await this.attachSubAgentDelegationTool({
agent,
@@ -23,7 +23,6 @@ export function buildAgentConfigurationTelemetryFromConfig(
]);
const providerToolCount = Object.keys(config?.providerTools ?? {}).length;
const hasWebSearch = config?.config?.webSearch?.enabled === true;
const hasNodeTools = config?.config?.nodeTools?.enabled === true;
const mcpServerCount = config?.mcpServers?.length ?? 0;
const subAgentCount = config?.subAgents?.agents?.length ?? 0;
@@ -31,7 +30,6 @@ export function buildAgentConfigurationTelemetryFromConfig(
if (mcpServerCount > 0) toolTypes.add('mcp');
if (providerToolCount > 0) toolTypes.add('provider');
if (hasWebSearch) toolTypes.add('web_search');
if (hasNodeTools) toolTypes.add('node_tools');
if (subAgentCount > 0) toolTypes.add('subagent');
return {
@@ -43,7 +41,6 @@ export function buildAgentConfigurationTelemetryFromConfig(
mcpServerCount +
providerToolCount +
(hasWebSearch ? 1 : 0) +
(hasNodeTools ? 1 : 0) +
subAgentCount,
num_skills: config?.skills?.length ?? 0,
memory_type: getMemoryType(config),
@@ -4,17 +4,14 @@ import {
AGENT_BUILDER_AVAILABLE_AI_UTILITY_TOOL_NODE_TYPES,
AGENT_BUILDER_HIDDEN_AVAILABLE_TOOL_NODE_TYPES,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Service } from '@n8n/di';
import { validateNodeConfig } from '@n8n/workflow-sdk';
import { isToolType, isTriggerNodeType } from 'n8n-workflow';
import type { IDataObject, INodeParameters } from 'n8n-workflow';
import { z } from 'zod';
import { MCP_REGISTRY_PACKAGE_NAME } from '../mcp-registry/node-description-transform';
import { NodeCatalogService } from '@/node-catalog';
import { EphemeralNodeExecutor, isAgentProviderNode } from '@/node-execution';
import { isAgentProviderNode } from '@/node-execution';
type NodeRequest =
| string
@@ -104,39 +101,13 @@ const listCredentialsInputSchema = z.object({
),
});
const runNodeInputSchema = z.object({
nodeType: z.string().describe('Tool node type identifier from search_nodes'),
nodeTypeVersion: nodeVersionSchema,
nodeParameters: z
.record(z.unknown())
.optional()
.describe(
'Static node config. Use expressions like ={{ $json.url }} to reference inputData fields.',
),
credentials: z
.record(z.object({ id: z.string(), name: z.string() }))
.optional()
.describe('Credential slot → { id, name }. Copy from list_credentials results.'),
inputData: z
.record(z.unknown())
.optional()
.describe('Runtime input, available as $json inside nodeParameters expressions.'),
});
@Service()
export class AgentsToolsService {
constructor(
private readonly logger: Logger,
private readonly nodeCatalogService: NodeCatalogService,
private readonly ephemeralNodeExecutor: EphemeralNodeExecutor,
) {}
constructor(private readonly nodeCatalogService: NodeCatalogService) {}
/**
* Tools usable from both the builder and the agent runtime.
*
* `listCredentialsUsageHint` lets each caller tailor the `list_credentials`
* description to its flow — the runtime points at `run_node_tool`, while the
* builder points at code generation.
* Tools usable by the builder while configuring node-backed tools.
* `listCredentialsUsageHint` lets callers tailor the credential guidance.
*/
getSharedTools(
credentialProvider: CredentialProvider,
@@ -149,17 +120,6 @@ export class AgentsToolsService {
];
}
/** Shared tools plus the runtime-only `run_node_tool` which binds to a project. */
getRuntimeTools(credentialProvider: CredentialProvider, projectId: string): BuiltTool[] {
return [
...this.getSharedTools(
credentialProvider,
'Call this before run_node_tool to know which credential to pass.',
),
this.buildRunNodeTool(projectId),
];
}
private buildSearchNodesTool(): BuiltTool {
return new Tool('search_nodes')
.description(
@@ -216,70 +176,11 @@ export class AgentsToolsService {
})
.build();
}
private buildRunNodeTool(projectId: string): BuiltTool {
return new Tool('run_node_tool')
.description(
'Execute an n8n node for the current request. ' +
'Use the tool nodeType and nodeTypeVersion from search_nodes. ' +
'Call get_node_types first to understand what nodeParameters the node accepts. ' +
'nodeParameters holds static node config; use n8n expressions like ={{ $json.url }} to map inputData fields. ' +
'credentials maps slot names to { id, name } — copy from the list_credentials results. ' +
'inputData is the runtime payload available as $json inside expressions. ' +
'Parameters are validated against the node schema before execution.',
)
.input(runNodeInputSchema)
.handler(async ({ nodeType, nodeTypeVersion, nodeParameters, credentials, inputData }) => {
if (!isExecutableNodeType(nodeType)) {
return {
status: 'error',
message: `Node type "${nodeType}" cannot be executed directly — trigger nodes are not supported here.`,
};
}
if (nodeParameters) {
const { valid, errors } = validateNodeConfig(
nodeType,
nodeTypeVersion,
{
parameters: nodeParameters,
},
{ isToolNode: true },
);
if (!valid) {
return {
status: 'error',
message: `Invalid nodeParameters: ${errors.map((e) => e.message).join('; ')}`,
};
}
}
try {
return await this.ephemeralNodeExecutor.executeInline({
nodeType,
nodeTypeVersion,
nodeParameters: (nodeParameters ?? {}) as INodeParameters,
credentialDetails: credentials,
inputData: [{ json: (inputData ?? {}) as IDataObject }],
projectId,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.logger.warn('run_node_tool execution failed', { nodeType, error });
return {
status: 'error',
message: `Node execution failed: ${message}`,
};
}
})
.build();
}
}
/**
* The catalog's `getNodeTypes` signature expects `version` as a string (matching the
* code-builder tool's wire format). Our public schema uses `number` for consistency
* with `run_node_tool`; adapt at the boundary.
* builder tool's wire format); adapt at the boundary.
*/
function normalizeNodeRequestForCatalog(req: NodeRequest):
| string
@@ -331,9 +331,9 @@ export class EphemeralNodeExecutor {
// Validation failures (unknown node type, trigger nodes, blacklisted
// operations like send-and-wait) need to surface to the agent as a
// tool error rather than crashing silently. Returning the standard
// `{ status: 'error', error }` shape lets `run_node_tool` translate
// it into a tool-result the LLM sees AND lets the ExecutionRecorder
// record it as a failed tool call in the session timeline.
// `{ status: 'error', error }` shape lets node tools surface the error
// to the LLM and lets the ExecutionRecorder record it as a failed tool
// call in the session timeline.
try {
this.validateNodeForExecution(
request.nodeType,
@@ -6748,8 +6748,6 @@
"agents.builder.tools.title": "Tools",
"agents.builder.tools.count": "{count} tool configured | {count} tools configured",
"agents.builder.tools.add": "Add tool",
"agents.builder.tools.builtIn.title": "Built-in node tools",
"agents.builder.tools.builtIn.hint": "Let the agent search the n8n node catalog and execute nodes on demand.",
"agents.builder.tools.empty": "No tools added yet.",
"agents.builder.tools.type.workflow": "Workflow",
"agents.builder.tools.type.custom": "Custom tool",
@@ -183,12 +183,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
return isOtelCustomSpanAttributesLicensed && isOtelModuleActive;
});
// Opt-in flag: the `node-tools-searcher` token must be listed in the backend
// `N8N_AGENTS_MODULES` env var for this to evaluate true.
const isAgentsNodeToolsFeatureEnabled = computed(() =>
isAgentModuleActive('node-tools-searcher'),
);
// Opt-in flag: requires `N8N_AGENTS_AI_SANDBOX_ENABLED=true` and
// `N8N_AGENTS_AI_SANDBOX_PROVIDER=daytona` on the backend.
const isAgentsKnowledgeBaseFeatureEnabled = computed(
@@ -492,7 +486,6 @@ export const useSettingsStore = defineStore(STORES.SETTINGS, () => {
isDataTableFeatureEnabled,
isChatFeatureEnabled,
isOtelCustomSpanAttributesEnabled,
isAgentsNodeToolsFeatureEnabled,
isAgentsKnowledgeBaseFeatureEnabled,
isPublicChatTriggerDisabled,
};
@@ -15,14 +15,12 @@ import {
N8nIcon,
N8nIconButton,
N8nCard,
N8nSwitch2,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useNodeHelpers } from '@/app/composables/useNodeHelpers';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { useSettingsStore } from '@/app/stores/settings.store';
import type { INode, INodeTypeDescription } from 'n8n-workflow';
import type { INodeUi } from '@/Interface';
@@ -49,22 +47,6 @@ const emit = defineEmits<{
'remove-tool': [index: number];
}>();
const settingsStore = useSettingsStore();
const nodeToolsFeatureEnabled = computed(() => settingsStore.isAgentsNodeToolsFeatureEnabled);
const nodeToolsEnabled = computed<boolean>(
() => props.config?.config?.nodeTools?.enabled !== false,
);
function setNodeToolsEnabled(enabled: boolean) {
emit('update:config', {
config: {
...(props.config?.config ?? {}),
nodeTools: { enabled },
},
});
}
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const nodeHelpers = useNodeHelpers();
@@ -169,21 +151,6 @@ const totalCount = computed(() => props.tools.length);
</template>
</AgentPanelHeader>
<div v-if="nodeToolsFeatureEnabled" :class="$style.toggleRow">
<div :class="$style.toggleText">
<N8nText :bold="true">{{ i18n.baseText('agents.builder.tools.builtIn.title') }}</N8nText>
<N8nText size="small" color="text-light">
{{ i18n.baseText('agents.builder.tools.builtIn.hint') }}
</N8nText>
</div>
<N8nSwitch2
size="large"
data-testid="node-tools-toggle"
:model-value="nodeToolsEnabled"
@update:model-value="setNodeToolsEnabled"
/>
</div>
<div v-if="totalCount === 0" :class="$style.empty">
<N8nText size="small" color="text-light">{{
i18n.baseText('agents.builder.tools.empty')
@@ -337,21 +304,6 @@ const totalCount = computed(() => props.tools.length);
scrollbar-color: var(--border-color) transparent;
}
.toggleRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing--sm);
padding: var(--spacing--xs) 0;
border-bottom: var(--border);
}
.toggleText {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
}
.empty {
padding: var(--spacing--lg);
text-align: center;