fix(core): Harden agent workflow tools against unpublished, missing, or incompatible workflows (#37772)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-09-04 13:03:53 +00:00
committed by GitHub
co-authored by Cursor
parent 5951b009e0
commit 199433204f
48 changed files with 1122 additions and 734 deletions
@@ -131,21 +131,4 @@ describe('getWorkflowToolIncompatibilityReason', () => {
nodeTypes: [incompatibleBodyNode],
});
});
it('ignores an incompatible node reachable only from a second supported trigger', () => {
// The agent invokes the first supported trigger only, so the second
// trigger's subgraph never runs and its incompatible node is ignored.
const secondTrigger = SUPPORTED_WORKFLOW_TOOL_TRIGGERS[1];
const result = getWorkflowToolIncompatibilityReason(
wfNamed(
[
{ type: compatibleTrigger, name: 'First' },
{ type: secondTrigger, name: 'Second' },
{ type: incompatibleBodyNode, name: 'Wait' },
],
link('Second', 'Wait'),
),
);
expect(result).toBeNull();
});
});
@@ -50,9 +50,9 @@ export const agentConfigValidationIssueSchema = z.object({
capability: agentConfigValidationCapabilityRefSchema,
/**
* Stable, machine-readable sub-reason for `incompatible_reference` issues
* (e.g. `'incompatible_nodes'`, `'no_supported_trigger'` for workflow tools).
* Absent when the code itself is specific enough. Frontends map this to a
* reason-specific i18n key; never display it raw.
* (e.g. `'incompatible_nodes'`, `'no_supported_trigger'`, `'not_published'`
* for workflow tools). Absent when the code itself is specific enough.
* Frontends map this to a reason-specific i18n key; never display it raw.
*/
reason: z.string().optional(),
});
+14 -16
View File
@@ -1,23 +1,21 @@
import {
CHAT_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
getChildNodes,
MANUAL_TRIGGER_NODE_TYPE,
WEBHOOK_NODE_TYPE,
type IConnections,
} from 'n8n-workflow';
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE, getChildNodes, type IConnections } from 'n8n-workflow';
import type { AgentIntegrationSettings } from './agent-integration.schema';
import type { AgentJsonConfig } from './agent-json-config.schema';
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
WEBHOOK_NODE_TYPE,
] as const;
export const SUPPORTED_WORKFLOW_TOOL_TRIGGERS = [EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE] as const;
/** Display name of each supported trigger, keyed by node type so a rename is a one-line change. */
const WORKFLOW_TOOL_TRIGGER_DISPLAY_NAMES: Record<
(typeof SUPPORTED_WORKFLOW_TOOL_TRIGGERS)[number],
string
> = {
[EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]: 'When Executed by Another Workflow',
};
/** Display name of the trigger a workflow tool has to start with, for backend copy. */
export const WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME =
WORKFLOW_TOOL_TRIGGER_DISPLAY_NAMES[EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE];
/**
* Body nodes a workflow tool cannot run. The Wait node is absent by design — the
@@ -337,7 +337,8 @@ export class WorkflowRepository extends BaseRepository<WorkflowEntity> {
// scope its check to nodes reachable from a supported trigger; without
// it the backend falls back to scanning every enabled node and
// disagrees with the frontend picker, which fetches connections.
select: ['id', 'name', 'nodes', 'connections'],
// `activeVersionId` tells the publish check whether the workflow is published.
select: ['id', 'name', 'nodes', 'connections', 'activeVersionId'],
});
}
@@ -18,6 +18,8 @@ import { AgentExecutionOrchestratorService } from '../agent-execution-orchestrat
import type { AgentExecutionService } from '../agent-execution.service';
import type { AgentRunTracingService } from '../agent-run-tracing.service';
import type { AgentRuntimeCacheService } from '../agent-runtime-cache.service';
import type { Agent } from '../entities/agent.entity';
import type { AgentRepository } from '../repositories/agent.repository';
import {
encodeAgentSandboxHostMetadata,
hashAgentSandboxPrincipal,
@@ -125,6 +127,7 @@ function makeService(sandboxEnabled = false) {
const agentSandboxRuntimeService = mock<AgentSandboxRuntimeService>({
isEnabled: () => sandboxEnabled,
});
const agentRepository = mock<AgentRepository>();
executionService.startExecutionRecording.mockResolvedValue('execution-1');
executionService.finalizeExecution.mockResolvedValue('execution-1');
@@ -140,6 +143,7 @@ function makeService(sandboxEnabled = false) {
agentRunTracingService,
externalHooks,
agentSandboxRuntimeService,
agentRepository,
);
return {
@@ -152,6 +156,7 @@ function makeService(sandboxEnabled = false) {
agentRunTracingService,
externalHooks,
agentSandboxRuntimeService,
agentRepository,
};
}
@@ -528,6 +533,77 @@ describe('AgentExecutionOrchestratorService', () => {
);
});
it('records a failed session and rethrows when the published runtime cannot be built', async () => {
const { service, runtimeCacheService, executionService, agentRepository } = makeService();
const buildError = new UserError('Credential "OpenAI" not found');
runtimeCacheService.getRuntime.mockRejectedValue(buildError);
// A plain object: `mock<Agent>()` proxies nested fields, which breaks the
// telemetry builder's array handling of `schema`.
agentRepository.findByIdAndProjectId.mockResolvedValue({
id: agentId,
name: 'Support Agent (draft)',
schema: { ...schema, name: 'Support Agent (draft)' },
activeVersion: { schema },
integrations: [],
} as unknown as Agent);
await expect(
collect(
service.executeForChatPublished({
agentId,
projectId,
message: 'from slack',
memory: { threadId: 'thread-1', resourceId: 'platform-user-1' },
integrationType: 'slack',
sandboxPrincipalHash: integrationPrincipalHash,
}),
),
).rejects.toBe(buildError);
expect(executionService.startExecutionRecording).toHaveBeenCalledWith(
expect.objectContaining({
agentId,
agentName: 'Support Agent',
threadId: 'thread-1',
userMessage: 'from slack',
source: 'slack',
telemetry: expect.objectContaining({ runType: 'production' }),
}),
expect.any(Date),
);
expect(executionService.finalizeExecution).toHaveBeenCalledWith(
'execution-1',
expect.objectContaining({
record: expect.objectContaining({
finishReason: 'error',
error: 'Credential "OpenAI" not found',
}),
}),
);
});
it('rethrows the build error without recording when the agent no longer exists', async () => {
const { service, runtimeCacheService, executionService, agentRepository } = makeService();
const buildError = new Error('boom');
runtimeCacheService.getRuntime.mockRejectedValue(buildError);
agentRepository.findByIdAndProjectId.mockResolvedValue(null);
await expect(
collect(
service.executeForTaskPublished({
agentId,
projectId,
message: 'run task',
memory: { threadId: 'thread-1', resourceId: 'task-run-1' },
taskId: 'task-1',
taskVersionId: 'version-1',
}),
),
).rejects.toBe(buildError);
expect(executionService.startExecutionRecording).not.toHaveBeenCalled();
});
it('executes published scheduled tasks with task-scoped runtime and metadata', async () => {
const {
service,
@@ -24,7 +24,6 @@ import type { OauthService } from '@/oauth/oauth.service';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { AiGatewayService } from '@/services/ai-gateway.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { Telemetry } from '@/telemetry';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -119,7 +118,6 @@ function makeRuntimeReconstructionService(
mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
mock<WorkflowRepository>(),
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
mock<AgentSecureRuntime>(),
mock<EphemeralNodeExecutor>(),
@@ -274,6 +272,7 @@ describe('AgentRuntimeReconstructionService integration tools', () => {
mock<AgentRunTracingService>(),
mock<ExternalHooks>(),
agentSandboxRuntimeService,
agentRepository,
);
agentIntegrationPersistenceService = new AgentIntegrationPersistenceService(
agentRepository,
@@ -10,7 +10,6 @@ import type { CredentialsFinderService } from '@/credentials/credentials-finder.
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { WorkflowRepository } from '@n8n/db';
@@ -67,7 +66,6 @@ function makeService() {
fileRepository,
mock<ActiveExecutions>(),
mock<WorkflowRepository>(),
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
@@ -9,11 +9,12 @@ import { mock } from 'vitest-mock-extended';
import type { ActiveExecutions } from '@/active-executions';
import type { CredentialsFinderService } from '@/credentials/credentials-finder.service';
import { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks';
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import { userHasScopes } from '@/permissions.ee/check-access';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import { WorkflowRunner } from '@/workflow-runner';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
@@ -25,16 +26,30 @@ import type { Agent } from '../entities/agent.entity';
import type { N8NCheckpointStorage } from '../integrations/n8n-checkpoint-storage';
import type { N8nMemory } from '../integrations/n8n-memory';
import type * as FromJsonConfig from '../json-config/from-json-config';
import type { ToolExecutor } from '../json-config/from-json-config';
import type { BuildFromJsonOptions, ToolExecutor } from '../json-config/from-json-config';
import type { AgentFileRepository } from '../repositories/agent-file.repository';
import type { AgentRepository } from '../repositories/agent.repository';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { SubAgentRunner } from '../sub-agents/sub-agent-runner';
import type * as WorkflowToolFactory from '../tools/workflow-tool-factory';
import { WorkflowToolUnavailableError } from '../tools/workflow-tool-unavailable-error';
import { WorkflowToolWorkflowLoader } from '../tools/workflow-tool-workflow-loader.service';
vi.mock('@/permissions.ee/check-access', () => ({
userHasScopes: vi.fn(),
}));
const resolveWorkflowToolMock = vi.fn();
vi.mock('../tools/workflow-tool-factory', async () => {
const actual = await vi.importActual<typeof WorkflowToolFactory>(
'../tools/workflow-tool-factory',
);
return {
...actual,
resolveWorkflowTool: (...args: unknown[]) => resolveWorkflowToolMock(...args),
};
});
const projectId = 'project-1';
const userId = 'user-1';
const testUser = mock<User>({ id: userId });
@@ -122,7 +137,6 @@ function makeService(overrides: {
mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
workflowRepository,
mock<UrlService>(),
mock<N8NCheckpointStorage>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
@@ -139,7 +153,26 @@ function makeService(overrides: {
mock<AgentChatAttachmentService>(),
);
return { service, credentialsFinderService, workflowFinderService, workflowRepository };
return {
service,
credentialsFinderService,
workflowFinderService,
workflowRepository,
};
}
/** Routes every tool ref through `resolveTool`, as the real `buildFromJson` does. */
function buildFromJsonResolvingTools(resolved: Array<agents.BuiltTool | null | undefined>) {
// The workflow tool context is assembled from the container.
Container.set(WorkflowToolWorkflowLoader, mock<WorkflowToolWorkflowLoader>());
Container.set(WorkflowRunner, mock<WorkflowRunner>());
Container.set(SubworkflowPolicyChecker, mock<SubworkflowPolicyChecker>());
buildFromJsonMock.mockImplementationOnce(
async (config: AgentJsonConfig, _descriptors: unknown, options: BuildFromJsonOptions) => {
for (const ref of config.tools ?? []) resolved.push(await options.resolveTool?.(ref));
return builtAgent;
},
);
}
function toolNamesPassedToBuildFromJson(): string[] {
@@ -316,6 +349,42 @@ describe('AgentRuntimeReconstructionService — per-user tool filtering', () =>
workflowIds: ['wf-1'],
});
});
it('stubs a workflow tool that cannot be built so a call reports the reason', async () => {
const { service } = makeService({});
resolveWorkflowToolMock.mockRejectedValue(
new WorkflowToolUnavailableError('not_found', 'Workflow "Lookup customer" not found'),
);
const resolved: Array<agents.BuiltTool | null | undefined> = [];
buildFromJsonResolvingTools(resolved);
await service.reconstructFromAgentEntity(
makeAgentEntity([workflowTool]),
mock<CredentialProvider>(),
'production',
);
expect(resolved).toHaveLength(1);
expect(resolved[0]?.name).toBe('lookup-customer');
// The stub reloads the workflow on call; the container's loader mock finds none.
await expect(resolved[0]?.handler?.({}, mock())).rejects.toThrow(
'Workflow "Lookup customer" is no longer accessible',
);
});
it('still fails the build for any other workflow tool error', async () => {
const { service } = makeService({});
resolveWorkflowToolMock.mockRejectedValue(new Error('runner unavailable'));
buildFromJsonResolvingTools([]);
await expect(
service.reconstructFromAgentEntity(
makeAgentEntity([workflowTool]),
mock<CredentialProvider>(),
'production',
),
).rejects.toThrow('runner unavailable');
});
});
describe('AgentRuntimeReconstructionService.reconstructFromResolvedSource — per-user tool filtering', () => {
@@ -26,6 +26,15 @@ const runnableConfig: AgentJsonConfig = {
skills: [],
};
const executeWorkflowTriggerNode = {
id: 'trigger-node-id',
name: 'When Executed by Another Workflow',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: { inputSource: 'passthrough' },
};
function makeAgent(
config: AgentJsonConfig | null = runnableConfig,
skills = {},
@@ -1169,30 +1178,15 @@ describe('AgentValidationService — structured issues', () => {
{
id: 'wf-a',
name: 'Workflow A',
nodes: [
{
id: 'trigger-node-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
activeVersionId: 'version-a',
nodes: [executeWorkflowTriggerNode],
},
{ id: 'wf-c', name: 'Workflow C', nodes: [] },
{
id: 'wf-form',
name: 'Workflow With Form',
nodes: [
{
id: 'trigger-2',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
executeWorkflowTriggerNode,
{
id: 'form-1',
name: 'Form',
@@ -1203,7 +1197,11 @@ describe('AgentValidationService — structured issues', () => {
},
],
// Form is reachable from the trigger, so it actually runs and is flagged.
connections: { 'Manual Trigger': { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
connections: {
[executeWorkflowTriggerNode.name]: {
main: [[{ node: 'Form', type: 'main', index: 0 }]],
},
},
},
] as never);
@@ -1249,6 +1247,46 @@ describe('AgentValidationService — structured issues', () => {
]);
});
it('flags an unpublished workflow tool for publishing but not for runtime', async () => {
const { service, agentRepository, workflowRepository } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent({
...runnableConfig,
tools: [{ type: 'workflow', workflowId: 'wf-draft', workflow: 'Draft Workflow' }],
}),
);
workflowRepository.findManyByAgentToolReferences.mockResolvedValue([
{
id: 'wf-draft',
name: 'Draft Workflow',
activeVersionId: null,
nodes: [executeWorkflowTriggerNode],
},
] as never);
const credentials = makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]);
const publishResult = await service.validateAgentConfiguration(
agentId,
projectId,
credentials,
'publish',
);
const runtimeResult = await service.validateAgentIsRunnable(agentId, projectId, credentials);
expect(publishResult).toEqual({
status: 'invalid',
issues: [
{
code: 'incompatible_reference',
path: 'tools.0.workflowId',
capability: { kind: 'tool', id: 'Draft Workflow', index: 0, toolType: 'workflow' },
reason: 'not_published',
},
],
});
expect(runtimeResult).toEqual({ missing: [] });
});
it('loaded-agent full validation loads tasks but does not refetch the agent, flagging missing task bodies regardless of enabled state', async () => {
const { service, agentTaskRepository } = makeService();
const agent = makeAgent({
@@ -1365,14 +1365,21 @@ describe('AgentsBuilderToolsService', () => {
it('passes the search term to the attachable workflows service', async () => {
const { service, attachableWorkflowsService } = makeService();
attachableWorkflowsService.list.mockResolvedValue([
{ id: 'wf-1', name: 'Billing follow-up', active: true, triggerType: 'manual' },
{ id: 'wf-1', name: 'Billing follow-up', published: true, triggerType: 'executeWorkflow' },
]);
const result = await getListWorkflowsTool(service).handler!({ searchTerm: 'billing' }, ctx);
expect(attachableWorkflowsService.list).toHaveBeenCalledWith(user, projectId, 'billing');
expect(result).toEqual({
workflows: [{ id: 'wf-1', name: 'Billing follow-up', active: true, triggerType: 'manual' }],
workflows: [
{
id: 'wf-1',
name: 'Billing follow-up',
published: true,
triggerType: 'executeWorkflow',
},
],
});
});
});
@@ -26,7 +26,6 @@ import type { CredentialsFinderService } from '@/credentials/credentials-finder.
import type { EphemeralNodeExecutor } from '@/node-execution';
import type { OauthService } from '@/oauth/oauth.service';
import type { AiService } from '@/services/ai.service';
import type { UrlService } from '@/services/url.service';
import type { WorkflowFinderService } from '@/workflows/workflow-finder.service';
import type { AgentChatAttachmentService } from '../agent-chat-attachment.service';
@@ -119,7 +118,6 @@ function makeReconstructionService(
overrides.agentFileRepository ?? mock<AgentFileRepository>(),
mock<ActiveExecutions>(),
mock<WorkflowRepository>(),
mock<UrlService>(),
overrides.n8nCheckpointStorage ?? mock<N8NCheckpointStorage>(),
secureRuntime,
mock<EphemeralNodeExecutor>(),
@@ -9,17 +9,17 @@ function wf(overrides: Partial<WorkflowEntity>): WorkflowEntity {
return {
id: 'wf-1',
name: 'Workflow',
active: false,
activeVersionId: null,
nodes: [],
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
...overrides,
} as WorkflowEntity;
}
const manualTrigger = { type: 'n8n-nodes-base.manualTrigger' } as WorkflowEntity['nodes'][number];
const scheduleTrigger = {
type: 'n8n-nodes-base.scheduleTrigger',
const executeWorkflowTrigger = {
type: 'n8n-nodes-base.executeWorkflowTrigger',
} as WorkflowEntity['nodes'][number];
const manualTrigger = { type: 'n8n-nodes-base.manualTrigger' } as WorkflowEntity['nodes'][number];
const noTrigger = { type: 'n8n-nodes-base.set' } as WorkflowEntity['nodes'][number];
function setup() {
@@ -43,28 +43,35 @@ describe('AttachableWorkflowsService', () => {
);
});
it('returns only workflows with a supported trigger and stable ids', async () => {
it('returns only workflows with the execute-workflow trigger and stable ids', async () => {
const { service, workflowFinderService, user } = setup();
workflowFinderService.findWorkflowsForUser.mockResolvedValue({
workflows: [
wf({ id: 'a', name: 'Has trigger', active: true, nodes: [noTrigger, manualTrigger] }),
wf({
id: 'a',
name: 'Has trigger',
activeVersionId: 'v-a',
nodes: [noTrigger, executeWorkflowTrigger],
}),
wf({ id: 'b', name: 'No trigger', nodes: [noTrigger] }),
wf({ id: 'c', name: 'Schedule only', nodes: [scheduleTrigger] }),
wf({ id: 'c', name: 'Manual only', nodes: [manualTrigger] }),
],
count: 3,
});
const result = await service.list(user, 'project-1');
expect(result).toEqual([{ id: 'a', name: 'Has trigger', active: true, triggerType: 'manual' }]);
expect(result).toEqual([
{ id: 'a', name: 'Has trigger', published: true, triggerType: 'executeWorkflow' },
]);
});
it('dedupes workflows that surface via multiple share paths', async () => {
const { service, workflowFinderService, user } = setup();
workflowFinderService.findWorkflowsForUser.mockResolvedValue({
workflows: [
wf({ id: 'dup', name: 'Dup', nodes: [manualTrigger] }),
wf({ id: 'dup', name: 'Dup', nodes: [manualTrigger] }),
wf({ id: 'dup', name: 'Dup', nodes: [executeWorkflowTrigger] }),
wf({ id: 'dup', name: 'Dup', nodes: [executeWorkflowTrigger] }),
],
count: 2,
});
@@ -78,8 +85,8 @@ describe('AttachableWorkflowsService', () => {
const { service, workflowFinderService, user } = setup();
workflowFinderService.findWorkflowsForUser.mockResolvedValue({
workflows: [
wf({ id: 'a', name: 'Billing follow-up', nodes: [manualTrigger] }),
wf({ id: 'b', name: 'Sales outreach', nodes: [manualTrigger] }),
wf({ id: 'a', name: 'Billing follow-up', nodes: [executeWorkflowTrigger] }),
wf({ id: 'b', name: 'Sales outreach', nodes: [executeWorkflowTrigger] }),
],
count: 2,
});
@@ -87,7 +94,7 @@ describe('AttachableWorkflowsService', () => {
const result = await service.list(user, 'project-1', 'billing');
expect(result).toEqual([
{ id: 'a', name: 'Billing follow-up', active: false, triggerType: 'manual' },
{ id: 'a', name: 'Billing follow-up', published: false, triggerType: 'executeWorkflow' },
]);
});
@@ -97,7 +104,7 @@ describe('AttachableWorkflowsService', () => {
wf({
id: `wf-${index}`,
name: `Workflow ${index}`,
nodes: [manualTrigger],
nodes: [executeWorkflowTrigger],
updatedAt: new Date(Date.UTC(2026, 0, index + 1)),
}),
);
@@ -112,14 +119,14 @@ describe('AttachableWorkflowsService', () => {
expect(result[0]).toEqual({
id: 'wf-59',
name: 'Workflow 59',
active: false,
triggerType: 'manual',
published: false,
triggerType: 'executeWorkflow',
});
expect(result.at(-1)).toEqual({
id: 'wf-50',
name: 'Workflow 50',
active: false,
triggerType: 'manual',
published: false,
triggerType: 'executeWorkflow',
});
});
@@ -136,7 +143,7 @@ describe('AttachableWorkflowsService', () => {
wf({
id: `wf-${i}`,
name: `Workflow ${i}`,
nodes: [manualTrigger],
nodes: [executeWorkflowTrigger],
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
}),
);
@@ -160,7 +167,7 @@ describe('AttachableWorkflowsService', () => {
wf({
id: `wf-${i}`,
name: `Workflow ${i}`,
nodes: i >= 10 ? [noTrigger] : [manualTrigger],
nodes: i >= 10 ? [noTrigger] : [executeWorkflowTrigger],
updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, i)),
}),
);
@@ -174,7 +181,7 @@ describe('AttachableWorkflowsService', () => {
// A cap applied before filtering would waste slots on the newest
// no-trigger entries and return fewer than 10.
expect(result).toHaveLength(10);
expect(result.every((w) => w.triggerType === 'manual')).toBe(true);
expect(result.every((w) => w.triggerType === 'executeWorkflow')).toBe(true);
expect(result[0].name).toBe('Workflow 9');
});
});
@@ -569,6 +569,23 @@ describe('buildFromJson()', () => {
expect(tool!.approval).toBeUndefined();
});
it('drops a workflow tool when resolveTool returns null', async () => {
const config = makeConfig({ tools: [{ type: 'workflow', workflow: 'Deleted Workflow' }] });
const agent = await buildFromJson(
config,
{},
{
toolExecutor: makeMockToolExecutor(),
credentialProvider: makeMockCredentialProvider(),
memoryFactory: makeMockMemoryFactory(),
resolveTool: vi.fn().mockResolvedValue(null),
},
);
expect(agent.snapshot.tools.some((t) => t.name === 'Deleted Workflow')).toBe(false);
});
it('falls back to marker tool when resolveTool is not provided for workflow tools', async () => {
const config = makeConfig({ tools: [{ type: 'workflow', workflow: 'Test Workflow' }] });
@@ -9,12 +9,13 @@ import type { SubworkflowPolicyChecker } from '@/executions/pre-execution-checks
import type { WorkflowRunner } from '@/workflow-runner';
import {
buildUnavailableWorkflowTool,
detectTriggerNode,
normalizeTriggerInput,
resolveWorkflowTool,
validateCompatibility,
} from '../tools/workflow-tool-factory';
import type { WorkflowToolContext } from '../tools/workflow-tool-factory';
import { WorkflowToolUnavailableError } from '../tools/workflow-tool-unavailable-error';
import { findWorkflowToolWorkflows } from '../tools/workflow-tool-workflow-resolver';
import type { WorkflowToolWorkflowLoader } from '../tools/workflow-tool-workflow-loader.service';
@@ -22,41 +23,28 @@ import type { WorkflowToolWorkflowLoader } from '../tools/workflow-tool-workflow
// Helpers
// ---------------------------------------------------------------------------
function makeManualTriggerNode(overrides: Partial<INode> = {}): INode {
const TRIGGER_NAME = 'When Executed by Another Workflow';
function makeExecuteWorkflowTriggerNode(overrides: Partial<INode> = {}): INode {
return {
id: 'trigger-node-id',
name: TRIGGER_NAME,
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: { inputSource: 'passthrough' },
...overrides,
};
}
function makeManualTriggerNode(): INode {
return {
id: 'manual-trigger-id',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
...overrides,
};
}
function makeFormTriggerNode(overrides: Partial<INode> = {}): INode {
return {
id: 'trigger-node-id',
name: 'Form Trigger',
type: 'n8n-nodes-base.formTrigger',
typeVersion: 1,
position: [0, 0],
parameters: { path: 'my-form' },
webhookId: 'webhook-abc',
...overrides,
};
}
function makeWebhookTriggerNode(overrides: Partial<INode> = {}): INode {
return {
id: 'webhook-node-id',
name: 'Webhook',
type: 'n8n-nodes-base.webhook',
typeVersion: 2,
position: [0, 0],
parameters: { responseMode: 'responseNode' },
webhookId: 'webhook-abc',
...overrides,
};
}
@@ -74,7 +62,7 @@ function makeRespondToWebhookNode(overrides: Partial<INode> = {}): INode {
function makeWorkflow(
overrides: Partial<WorkflowEntity> = {},
triggerNode: INode = makeManualTriggerNode(),
triggerNode: INode = makeExecuteWorkflowTriggerNode(),
): WorkflowEntity {
return {
id: 'workflow-123',
@@ -105,6 +93,8 @@ function makeContext(foundWorkflow: WorkflowEntity | null): WorkflowToolContext
};
}
const DRAFT_LOAD_OPTIONS = { usePublishedVersion: false };
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -114,51 +104,25 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
Container.reset();
});
it('attaches metadata with triggerType "manual" for a manual trigger workflow', async () => {
const workflow = makeWorkflow(
{ id: 'wf-manual-1', name: 'Manual Workflow' },
makeManualTriggerNode(),
);
it('attaches workflow metadata to the built tool', async () => {
const workflow = makeWorkflow({ id: 'wf-1', name: 'Execute Workflow' });
const context = makeContext(workflow);
const tool = await resolveWorkflowTool(
{ type: 'workflow', workflow: 'Manual Workflow' },
{ type: 'workflow', workflow: 'Execute Workflow' },
context,
);
expect(tool.metadata).toEqual({
kind: 'workflow',
workflowId: 'wf-manual-1',
workflowName: 'Manual Workflow',
triggerType: 'manual',
});
});
it('attaches metadata with triggerType "form" for a form trigger workflow', async () => {
const workflow = makeWorkflow(
{ id: 'wf-form-2', name: 'Form Workflow' },
makeFormTriggerNode(),
);
const context = makeContext(workflow);
const tool = await resolveWorkflowTool(
{ type: 'workflow', workflow: 'Form Workflow' },
context,
);
expect(tool.metadata).toEqual({
kind: 'workflow',
workflowId: 'wf-form-2',
workflowName: 'Form Workflow',
triggerType: 'form',
workflowId: 'wf-1',
workflowName: 'Execute Workflow',
triggerType: 'executeWorkflow',
});
});
it('resolves a renamed workflow by id', async () => {
const workflow = makeWorkflow(
{ id: 'wf-id-99', name: 'canonical-name' },
makeManualTriggerNode(),
);
const workflow = makeWorkflow({ id: 'wf-id-99', name: 'canonical-name' });
const context = makeContext(workflow);
const tool = await resolveWorkflowTool(
@@ -186,7 +150,7 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
expect(context.workflowLoader.loadWorkflow).toHaveBeenCalledWith(
'project-1',
{ workflowName: 'Scoped Workflow' },
{ usePublishedVersion: false },
DRAFT_LOAD_OPTIONS,
);
});
@@ -216,16 +180,58 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
expect(context.workflowLoader.loadWorkflow).toHaveBeenCalledWith(
'project-1',
{ workflowId: 'missing-id', workflowName: 'Existing Workflow' },
{ usePublishedVersion: false },
DRAFT_LOAD_OPTIONS,
);
});
it('throws when the workflow is not shared with the project', async () => {
it('reports a missing workflow as unavailable with reason not_found', async () => {
const context = makeContext(null);
await expect(
resolveWorkflowTool({ type: 'workflow', workflow: 'Missing Workflow' }, context),
).rejects.toThrow('Workflow "Missing Workflow" not found');
).rejects.toMatchObject({
constructor: WorkflowToolUnavailableError,
reason: 'not_found',
message: 'Workflow "Missing Workflow" not found',
});
});
it('reports a workflow without the execute-workflow trigger as unavailable with reason incompatible', async () => {
const context = makeContext(makeWorkflow({ name: 'Manual Only' }, makeManualTriggerNode()));
await expect(
resolveWorkflowTool({ type: 'workflow', workflow: 'Manual Only' }, context),
).rejects.toMatchObject({
constructor: WorkflowToolUnavailableError,
reason: 'incompatible',
});
});
it('runs a stubbed workflow tool as soon as the workflow is fixed', async () => {
const broken = makeWorkflow({ id: 'wf-1', name: 'Fixable' }, makeManualTriggerNode());
const fixed = makeWorkflow({ id: 'wf-1', name: 'Fixable' });
const context = makeContext(broken);
context.workflowLoader.loadWorkflow = vi
.fn()
.mockResolvedValueOnce(broken)
.mockResolvedValueOnce(fixed);
context.workflowRunner.run = vi.fn().mockResolvedValue('exec-1');
context.activeExecutions.has = vi.fn().mockReturnValue(false);
Container.set(ExecutionPersistence, {
findSingleExecution: vi
.fn()
.mockResolvedValue({ status: 'success', data: { resultData: { runData: {} } } }),
} as unknown as ExecutionPersistence);
const descriptor = { type: 'workflow' as const, workflowId: 'wf-1', workflow: 'Fixable' };
const stub = buildUnavailableWorkflowTool(descriptor, context);
await expect(stub.handler?.({}, {})).rejects.toThrow(
"needs a 'When Executed by Another Workflow' trigger",
);
await expect(stub.handler?.({}, {})).resolves.toMatchObject({
executionId: 'exec-1',
status: 'success',
});
});
it('loads the current workflow for every invocation', async () => {
@@ -234,13 +240,19 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
id: 'wf-current',
name: 'Current Workflow',
versionId: 'version-2',
nodes: [makeManualTriggerNode(), { ...makeRespondToWebhookNode(), name: 'Version 2' }],
nodes: [
makeExecuteWorkflowTriggerNode(),
{ ...makeRespondToWebhookNode(), name: 'Version 2' },
],
});
const thirdVersion = makeWorkflow({
id: 'wf-current',
name: 'Current Workflow',
versionId: 'version-3',
nodes: [makeManualTriggerNode(), { ...makeRespondToWebhookNode(), name: 'Version 3' }],
nodes: [
makeExecuteWorkflowTriggerNode(),
{ ...makeRespondToWebhookNode(), name: 'Version 3' },
],
});
const context = makeContext(initial);
const loadWorkflow = vi
@@ -271,25 +283,18 @@ describe('resolveWorkflowTool() — metadata attachment', () => {
await tool.handler?.({}, {});
const expectedReference = { workflowId: 'wf-current', workflowName: 'Current Workflow' };
const expectedOptions = { usePublishedVersion: false };
expect(loadWorkflow).toHaveBeenNthCalledWith(
1,
'project-1',
expectedReference,
expectedOptions,
);
expect(loadWorkflow).toHaveBeenNthCalledWith(
2,
'project-1',
expectedReference,
expectedOptions,
);
expect(loadWorkflow).toHaveBeenNthCalledWith(
3,
'project-1',
expectedReference,
expectedOptions,
DRAFT_LOAD_OPTIONS,
);
expect(loadWorkflow).toHaveBeenNthCalledWith(2, 'project-1', expectedReference, {
usePublishedVersion: false,
});
expect(loadWorkflow).toHaveBeenNthCalledWith(3, 'project-1', expectedReference, {
usePublishedVersion: false,
});
expect(context.workflowRunner.run).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
@@ -317,15 +322,17 @@ describe('workflow tool compatibility', () => {
it('rejects workflows with only a schedule trigger', () => {
const workflow = makeWorkflow(
{},
makeManualTriggerNode({ type: 'n8n-nodes-base.scheduleTrigger' }),
makeExecuteWorkflowTriggerNode({ type: 'n8n-nodes-base.scheduleTrigger' }),
);
expect(() => detectTriggerNode(workflow)).toThrow('no supported trigger node');
expect(() => detectTriggerNode(workflow)).toThrow(
"needs a 'When Executed by Another Workflow' trigger",
);
});
it('allows Respond to Webhook nodes in workflow tools', () => {
const workflow = makeWorkflow({
nodes: [makeWebhookTriggerNode(), makeRespondToWebhookNode()],
nodes: [makeExecuteWorkflowTriggerNode(), makeRespondToWebhookNode()],
});
expect(() => validateCompatibility(workflow)).not.toThrow();
@@ -335,7 +342,7 @@ describe('workflow tool compatibility', () => {
it('allows a reachable Wait node in workflow tools', () => {
const workflow = makeWorkflow({
nodes: [
makeManualTriggerNode(),
makeExecuteWorkflowTriggerNode(),
{
id: 'wait-node-id',
name: 'Wait',
@@ -345,7 +352,7 @@ describe('workflow tool compatibility', () => {
parameters: { resume: 'webhook' },
},
],
connections: { 'Manual Trigger': { main: [[{ node: 'Wait', type: 'main', index: 0 }]] } },
connections: { [TRIGGER_NAME]: { main: [[{ node: 'Wait', type: 'main', index: 0 }]] } },
});
expect(() => validateCompatibility(workflow)).not.toThrow();
@@ -356,7 +363,7 @@ describe('workflow tool compatibility', () => {
it('rejects a reachable Form node in workflow tools', () => {
const workflow = makeWorkflow({
nodes: [
makeManualTriggerNode(),
makeExecuteWorkflowTriggerNode(),
{
id: 'form-node-id',
name: 'Form',
@@ -366,59 +373,11 @@ describe('workflow tool compatibility', () => {
parameters: {},
},
],
connections: { 'Manual Trigger': { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
connections: { [TRIGGER_NAME]: { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
});
expect(() => validateCompatibility(workflow)).toThrow("aren't supported as agent tools");
});
it('attaches metadata with triggerType "webhook" for a webhook trigger workflow', async () => {
const workflow = makeWorkflow(
{ id: 'wf-webhook-1', name: 'Webhook Workflow' },
makeWebhookTriggerNode(),
);
const context = makeContext(workflow);
const tool = await resolveWorkflowTool(
{ type: 'workflow', workflow: 'Webhook Workflow' },
context,
);
expect(tool.metadata).toEqual({
kind: 'workflow',
workflowId: 'wf-webhook-1',
workflowName: 'Webhook Workflow',
triggerType: 'webhook',
});
});
it('normalizes webhook tool input into the webhook trigger output shape', () => {
const triggerNode = makeWebhookTriggerNode();
const pinData = normalizeTriggerInput(
triggerNode,
'webhook',
{
customerId: '123',
priority: 'high',
},
'integrated',
);
expect(pinData).toEqual({
Webhook: [
{
json: {
headers: {},
params: {},
query: {},
body: { customerId: '123', priority: 'high' },
webhookUrl: '',
executionMode: 'production',
},
},
],
});
});
});
describe('findWorkflowToolWorkflows', () => {
@@ -23,7 +23,11 @@ import {
type StartExecutionParams,
} from './agent-execution.service';
import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service';
import { AgentRuntimeCacheService } from './agent-runtime-cache.service';
import {
AgentRuntimeCacheService,
type AgentRuntime,
type GetRuntimeParams,
} from './agent-runtime-cache.service';
import {
decodeAgentSandboxHostMetadata,
encodeAgentSandboxHostMetadata,
@@ -31,12 +35,15 @@ import {
type AgentSandboxPrincipalHash,
} from './agent-sandbox-principal';
import { AgentSandboxRuntimeService } from './agent-sandbox-runtime.service';
import { buildAgentConfigurationTelemetry } from './agent-telemetry';
import { buildToolCallDetails, ExecutionRecorder, type MessageRecord } from './execution-recorder';
import { IntegrationMessageContextService } from './integrations/integration-message-context.service';
import { N8NCheckpointStorage } from './integrations/n8n-checkpoint-storage';
import { AgentRepository } from './repositories/agent.repository';
import type { ToolRegistry } from './tool-registry';
import type { StoredAttachmentRef } from './agent-chat-attachment.service';
import { createAgentExecutionCounter } from './utils/agent-execution-counter';
import { getPublishedAgentSnapshot } from './utils/agent-published-snapshot';
import { buildInboundUserMessage } from './utils/inbound-attachments';
import { streamAgentChunks } from './utils/agent-stream';
import { executionsToMessagesDto } from './utils/execution-to-message-mapper';
@@ -269,6 +276,7 @@ export class AgentExecutionOrchestratorService {
private readonly agentRunTracingService: AgentRunTracingService,
private readonly externalHooks: ExternalHooks,
private readonly agentSandboxRuntimeService: AgentSandboxRuntimeService,
private readonly agentRepository: AgentRepository,
) {}
/**
@@ -604,13 +612,16 @@ export class AgentExecutionOrchestratorService {
// Published integration runtimes have no n8n user but are isolated by
// their external caller's hashed workspace principal.
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
integrationType,
usePublishedVersion: true,
sandboxPrincipalHash,
});
const runtime = await this.getPublishedRuntimeOrRecordFailure(
{
agentId,
projectId,
integrationType,
usePublishedVersion: true,
sandboxPrincipalHash,
},
{ threadId: memory.threadId, userMessage: message, attachments, source: integrationType },
);
try {
yield* this.streamChatResponse({
@@ -645,13 +656,16 @@ export class AgentExecutionOrchestratorService {
// Cron-fired runs have no n8n user and reuse the scheduled task's scope.
const sandboxPrincipalHash = hashAgentSandboxPrincipal({ type: 'scheduled-task', taskId });
const runtime = await this.runtimeCacheService.getRuntime({
agentId,
projectId,
integrationType: 'task',
usePublishedVersion: true,
sandboxPrincipalHash,
});
const runtime = await this.getPublishedRuntimeOrRecordFailure(
{
agentId,
projectId,
integrationType: 'task',
usePublishedVersion: true,
sandboxPrincipalHash,
},
{ threadId: memory.threadId, userMessage: message, source: 'task', taskId, taskVersionId },
);
try {
yield* this.streamChatResponse({
@@ -840,6 +854,74 @@ export class AgentExecutionOrchestratorService {
}
}
/**
* Build the published runtime, or record the failure as an errored session
* before rethrowing. `streamChatResponse` only starts recording once it has
* a runtime, so without this a broken tool or credential leaves no trace in
* Agent Sessions and the channel only sees a generic error.
*/
private async getPublishedRuntimeOrRecordFailure(
params: GetRuntimeParams,
session: Pick<
StartExecutionParams,
'threadId' | 'userMessage' | 'attachments' | 'source' | 'taskId' | 'taskVersionId'
>,
): Promise<AgentRuntime> {
try {
return await this.runtimeCacheService.getRuntime(params);
} catch (error) {
try {
await this.recordFailedStart(params, session, error);
} catch (recordError) {
this.logger.warn('Failed to record agent execution', {
agentId: params.agentId,
threadId: session.threadId,
error: recordError instanceof Error ? recordError.message : String(recordError),
});
}
throw error;
}
}
private async recordFailedStart(
{ agentId, projectId }: GetRuntimeParams,
session: Pick<
StartExecutionParams,
'threadId' | 'userMessage' | 'attachments' | 'source' | 'taskId' | 'taskVersionId'
>,
error: unknown,
): Promise<void> {
const agent = await this.agentRepository.findByIdAndProjectId(agentId, projectId);
if (!agent) return;
// Production runs execute the published snapshot, so name the session and
// build telemetry from it rather than from a draft that may have moved on.
const published = agent.activeVersion?.schema ? getPublishedAgentSnapshot(agent) : agent;
const recorder = new ExecutionRecorder();
recorder.record({ type: 'error', error });
recorder.record({ type: 'finish', finishReason: 'error' });
const startParams: StartExecutionParams = {
...session,
agentId,
agentName: published.schema?.name ?? agent.name,
projectId,
telemetry: {
runType: 'production',
configuration: buildAgentConfigurationTelemetry(published),
},
};
const executionId = await this.tryStartExecution(
startParams,
recorder.startedAt,
'Failed to start agent execution recording',
);
await this.persistRecordedExecution({
executionId,
params: { ...startParams, record: recorder.getMessageRecord() },
failureMessage: 'Failed to record agent execution',
});
}
private createRecorder(
toolRegistry: ToolRegistry,
getExecutionId: () => string | undefined,
@@ -43,7 +43,6 @@ import { McpRegistryService } from '@/modules/mcp-registry/registry/mcp-registry
import { userHasScopes } from '@/permissions.ee/check-access';
import { AiService } from '@/services/ai.service';
import { ProxyTokenManager } from '@/services/proxy-token-manager';
import { UrlService } from '@/services/url.service';
import { createAiMcpFetch, createAiProxyFetch, createWebSearchFetch } from '@/utils/ai-proxy-fetch';
import { WorkflowRunner } from '@/workflow-runner';
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
@@ -85,6 +84,7 @@ import { SubAgentRunner } from './sub-agents/sub-agent-runner';
import { buildToolRegistry, type ToolRegistry } from './tool-registry';
import { createGetEnvironmentTool } from './tools/environment-tool';
import type { WorkflowToolExecutionMode } from './tools/workflow-tool-factory';
import { WorkflowToolUnavailableError } from './tools/workflow-tool-unavailable-error';
import { findWorkflowToolWorkflow } from './tools/workflow-tool-workflow-resolver';
import { WorkflowToolWorkflowLoader } from './tools/workflow-tool-workflow-loader.service';
import { resolveUniqueSubAgents } from './utils/sub-agent-resolver';
@@ -185,6 +185,22 @@ export interface UserToolAccessSnapshot {
workflowIds: string[];
}
/**
* A configured node/workflow tool the runtime cannot use, and why. `no_access`
* tools are dropped; the rest stay as stubs that report the reason when called.
*/
export interface UnavailableTool {
toolName: string;
toolType: 'workflow' | 'node';
reason: 'not_found' | 'not_published' | 'incompatible' | 'no_access';
message: string;
}
function toolRefName(ref: AgentJsonToolConfig): string {
if (ref.type === 'custom') return ref.id;
return ref.type === 'workflow' ? (ref.name ?? ref.workflow) : ref.name;
}
@Service()
export class AgentRuntimeReconstructionService {
constructor(
@@ -193,7 +209,6 @@ export class AgentRuntimeReconstructionService {
private readonly agentFileRepository: AgentFileRepository,
private readonly activeExecutions: ActiveExecutions,
private readonly workflowRepository: WorkflowRepository,
private readonly urlService: UrlService,
private readonly n8nCheckpointStorage: N8NCheckpointStorage,
private readonly secureRuntime: AgentSecureRuntime,
private readonly ephemeralNodeExecutor: EphemeralNodeExecutor,
@@ -237,10 +252,12 @@ export class AgentRuntimeReconstructionService {
// execute or lacks credential/workflow access to before the runtime is
// built, so denied tools never reach the LLM or the executor.
let userToolAccessSnapshot: UserToolAccessSnapshot | undefined;
let unavailableTools: UnavailableTool[] = [];
if (user && config.tools?.length) {
const filtered = await this.filterToolsForUser(config.tools, agentEntity.projectId, user);
config = { ...config, tools: filtered.tools };
userToolAccessSnapshot = filtered.snapshot;
unavailableTools = filtered.unavailable;
}
const toolsByName: Record<string, string> = {};
@@ -274,6 +291,7 @@ export class AgentRuntimeReconstructionService {
user,
instrumentation,
sandboxPrincipalHash,
unavailableTools,
});
return {
...runtime,
@@ -288,26 +306,40 @@ export class AgentRuntimeReconstructionService {
* than the resolved tools) means a denied ref never reaches
* `makeToolResolver`/`resolveToolRef`, so no inert marker tool is exposed to
* the LLM. Custom tools are untouched — they run n8n-authored code, not a
* caller-chosen node/workflow with baked credentials.
* caller-chosen node/workflow with baked credentials. Every dropped ref is
* reported in `unavailable` so the build can log and track it.
*/
private async filterToolsForUser(
tools: AgentJsonToolConfig[],
projectId: string,
user: User,
): Promise<{ tools: AgentJsonToolConfig[]; snapshot?: UserToolAccessSnapshot }> {
): Promise<{
tools: AgentJsonToolConfig[];
snapshot?: UserToolAccessSnapshot;
unavailable: UnavailableTool[];
}> {
const canExecute = await userHasScopes(user, ['workflow:execute'], false, { projectId });
const filtered: AgentJsonToolConfig[] = [];
const unavailable: UnavailableTool[] = [];
const grantedCredentialIds = new Set<string>();
const grantedWorkflowIds = new Set<string>();
let keptGatedTool = false;
const drop = (
ref: Extract<AgentJsonToolConfig, { type: 'workflow' | 'node' }>,
reason: UnavailableTool['reason'],
message: string,
) => unavailable.push({ toolName: toolRefName(ref), toolType: ref.type, reason, message });
for (const ref of tools) {
if (ref.type === 'custom') {
filtered.push(ref);
continue;
}
if (!canExecute) continue;
if (!canExecute) {
drop(ref, 'no_access', 'The user lacks workflow:execute on the project');
continue;
}
if (ref.type === 'node') {
const credentialIds = Object.values(ref.node.credentials ?? {})
@@ -322,7 +354,10 @@ export class AgentRuntimeReconstructionService {
]),
),
);
if (accessibleCredentials.some((credential) => credential === null)) continue;
if (accessibleCredentials.some((credential) => credential === null)) {
drop(ref, 'no_access', 'The user cannot read a credential the tool uses');
continue;
}
for (const id of credentialIds) grantedCredentialIds.add(id);
keptGatedTool = true;
@@ -332,14 +367,21 @@ export class AgentRuntimeReconstructionService {
// ref.type === 'workflow'
const workflow = await findWorkflowToolWorkflow(this.workflowRepository, ref, projectId);
if (!workflow) continue;
if (!workflow) {
// Nothing to gate: the tool factory turns a missing workflow into a stub.
filtered.push(ref);
continue;
}
const accessibleWorkflow = await this.workflowFinderService.findWorkflowForUser(
workflow.id,
user,
['workflow:execute'],
);
if (!accessibleWorkflow) continue;
if (!accessibleWorkflow) {
drop(ref, 'no_access', `The user cannot execute workflow "${workflow.name}"`);
continue;
}
grantedWorkflowIds.add(workflow.id);
keptGatedTool = true;
@@ -348,6 +390,7 @@ export class AgentRuntimeReconstructionService {
return {
tools: filtered,
unavailable,
...(keptGatedTool
? {
snapshot: {
@@ -404,11 +447,13 @@ export class AgentRuntimeReconstructionService {
params: ReconstructAgentRuntimeParams,
): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
let config = params.config;
let unavailableTools: UnavailableTool[] = [];
if (params.user && config.tools?.length) {
// Sub-agent runtimes are built per delegation and never cached, so the
// grant snapshot is not needed here.
const filtered = await this.filterToolsForUser(config.tools, params.projectId, params.user);
config = { ...config, tools: filtered.tools };
unavailableTools = filtered.unavailable;
}
const subAgentDelegation = await this.createSubAgentDelegationConfig(config, params.projectId);
@@ -418,6 +463,7 @@ export class AgentRuntimeReconstructionService {
config,
credentialIntegrations: [],
subAgentDelegation,
unavailableTools,
});
}
@@ -445,6 +491,8 @@ export class AgentRuntimeReconstructionService {
instrumentation?: AgentRuntimeInstrumentation;
sandboxPrincipalHash?: AgentSandboxPrincipalHash;
parentWorkspace?: { handle: AgentSandboxRuntime; delegationThreadId: string };
/** Tools the access filter already dropped; reported together with build-time stubs. */
unavailableTools?: UnavailableTool[];
}): Promise<{ agent: RuntimeAgent; toolRegistry: ToolRegistry }> {
const {
config,
@@ -467,6 +515,7 @@ export class AgentRuntimeReconstructionService {
sandboxPrincipalHash,
parentWorkspace,
} = options;
const unavailable = [...(options.unavailableTools ?? [])];
const toolExecutor = this.secureRuntime.createToolExecutor(toolCodeByName);
// Callers that cannot resume a suspended run (agents invoked as workflow
@@ -497,6 +546,7 @@ export class AgentRuntimeReconstructionService {
Container.get(AgentsConfig).backgroundTasksEnabled,
},
instrumentation,
unavailable,
);
const resolvedTools: BuiltTool[] = [];
@@ -552,6 +602,14 @@ export class AgentRuntimeReconstructionService {
webSearchFetch,
});
if (unavailable.length > 0) {
this.logger.warn('Agent runtime built with unavailable tools', {
agentId: memoryOwnerAgentId,
runType,
tools: unavailable,
});
}
await this.injectRuntimeDependencies({
agent: reconstructed,
agentId: memoryOwnerAgentId,
@@ -659,7 +717,9 @@ export class AgentRuntimeReconstructionService {
supportsHitl: boolean;
backgroundTasksEnabled: boolean;
},
instrumentation?: AgentRuntimeInstrumentation,
instrumentation: AgentRuntimeInstrumentation | undefined,
/** Receives every workflow tool that had to be stubbed. */
unavailable: UnavailableTool[],
): ToolResolver {
const {
projectId,
@@ -674,8 +734,10 @@ export class AgentRuntimeReconstructionService {
const instrumentToolAdditionalData = instrumentation?.configureToolAdditionalData;
return async (ref: AgentJsonToolConfig) => {
if (ref.type === 'workflow') {
const { resolveWorkflowTool } = await import('./tools/workflow-tool-factory.js');
return await resolveWorkflowTool(ref, {
const { resolveWorkflowTool, buildUnavailableWorkflowTool } = await import(
'./tools/workflow-tool-factory.js'
);
const context = {
workflowLoader: Container.get(WorkflowToolWorkflowLoader),
workflowRunner: await getWorkflowRunner(),
subworkflowPolicyChecker: Container.get(SubworkflowPolicyChecker),
@@ -683,14 +745,27 @@ export class AgentRuntimeReconstructionService {
projectId,
executionMode: workflowToolExecutionMode,
usePublishedWorkflowVersion,
webhookBaseUrl: this.urlService.getWebhookBaseUrl(),
instrumentToolAdditionalData,
agentId,
integrationType,
userId,
supportsHitl,
backgroundTasksEnabled,
});
};
try {
return await resolveWorkflowTool(ref, context);
} catch (error) {
// A missing or incompatible workflow costs the agent one tool call, not
// the whole run: the stub keeps the tool listed and reports the reason.
if (!(error instanceof WorkflowToolUnavailableError)) throw error;
unavailable.push({
toolName: toolRefName(ref),
toolType: 'workflow',
reason: error.reason,
message: error.message,
});
return buildUnavailableWorkflowTool(ref, context);
}
}
if (ref.type === 'node') {
@@ -290,7 +290,7 @@ export class AgentValidationService {
this.collectTaskIssues(config, ctx.tasks, issues);
await this.collectChannelIssues(ctx.integrations, findCredential, issues);
}
await this.collectToolIssues(ctx, findCredential, workflowsByReference, issues);
await this.collectToolIssues(ctx, findCredential, workflowsByReference, issues, scope);
await this.collectMcpServerIssues(config, findCredential, issues);
return this.dedupe(issues);
@@ -536,6 +536,7 @@ export class AgentValidationService {
findCredential: FindCredential,
workflowsByReference: Map<string, WorkflowEntity>,
issues: AgentConfigValidationIssue[],
scope: AgentValidationScope,
) {
const tools = ctx.config.tools ?? [];
for (let index = 0; index < tools.length; index++) {
@@ -556,7 +557,7 @@ export class AgentValidationService {
}
if (tool.type === 'workflow') {
this.collectWorkflowToolIssues(tool, index, workflowsByReference, issues);
this.collectWorkflowToolIssues(tool, index, workflowsByReference, issues, scope);
continue;
}
@@ -571,6 +572,7 @@ export class AgentValidationService {
index: number,
workflowsByReference: Map<string, WorkflowEntity>,
issues: AgentConfigValidationIssue[],
scope: AgentValidationScope,
) {
const path = `tools.${index}.${tool.workflowId === undefined ? 'workflow' : 'workflowId'}`;
const capability: AgentConfigValidationIssue['capability'] = {
@@ -590,6 +592,14 @@ export class AgentValidationService {
const incompatibility = getWorkflowToolIncompatibilityReason(workflow);
if (incompatibility) {
issues.push(issue('incompatible_reference', path, capability, incompatibility.reason));
return;
}
// Production runs load the published workflow version, so an unpublished
// workflow blocks publishing the agent. Preview runs the draft and is not
// affected, hence publish scope only.
if (scope === 'publish' && !workflow.activeVersionId) {
issues.push(issue('incompatible_reference', path, capability, 'not_published'));
}
}
@@ -1,3 +1,4 @@
import { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
@@ -7,18 +8,12 @@ import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
export interface AttachableWorkflow {
id: string;
name: string;
active: boolean;
/** The published agent can only call published workflows. */
published: boolean;
triggerType: string;
}
// Keys are dotted n8n node type IDs; the naming-convention rule doesn't apply.
/* eslint-disable @typescript-eslint/naming-convention */
const SUPPORTED_TRIGGERS: Record<string, string> = {
'n8n-nodes-base.manualTrigger': 'manual',
'n8n-nodes-base.executeWorkflowTrigger': 'executeWorkflow',
'n8n-nodes-base.chatTrigger': 'chat',
'n8n-nodes-base.formTrigger': 'form',
};
const SUPPORTED_TRIGGER_TYPES: readonly string[] = SUPPORTED_WORKFLOW_TOOL_TRIGGERS;
// The result is embedded in an LLM tool response, so cap it because large tenants
// can have thousands of readable workflows in a project.
@@ -56,14 +51,16 @@ export class AttachableWorkflowsService {
)
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
.flatMap((workflow) => {
const triggerNode = (workflow.nodes ?? []).find((node) => SUPPORTED_TRIGGERS[node.type]);
if (!triggerNode) return [];
const hasSupportedTrigger = (workflow.nodes ?? []).some((node) =>
SUPPORTED_TRIGGER_TYPES.includes(node.type),
);
if (!hasSupportedTrigger) return [];
return [
{
id: workflow.id,
name: workflow.name,
active: workflow.active,
triggerType: SUPPORTED_TRIGGERS[triggerNode.type],
published: workflow.activeVersionId !== null,
triggerType: 'executeWorkflow',
},
];
})
@@ -31,6 +31,7 @@ import {
isDraftIntegration,
sanitizeAgentJsonConfig,
tryParseConfigJson,
WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME,
type AgentJsonConfig,
type ConfigValidationError,
} from '@n8n/api-types';
@@ -1323,7 +1324,9 @@ export class AgentsBuilderToolsService {
const listWorkflowsTool = new Tool(BUILDER_TOOLS.LIST_WORKFLOWS)
.description(
'List the n8n workflows that can be attached as tools via `type: "workflow"` in the agent config. ' +
'Only returns workflows with supported trigger types. Pass `searchTerm` to narrow by workflow name; ' +
`Only returns workflows that start with a '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' trigger. ` +
'The published agent cannot call a workflow with `published: false` until the user publishes it. ' +
'Pass `searchTerm` to narrow by workflow name; ' +
'omitting it returns the 10 most recently updated attachable workflows.',
)
.input(
@@ -5,7 +5,7 @@ import type { HttpRequestClient } from '@n8n/backend-network';
import { Container } from '@n8n/di';
import type { Author } from 'chat';
import { mock } from 'vitest-mock-extended';
import { type Logger } from 'n8n-workflow';
import { UserError, type Logger } from 'n8n-workflow';
import { CacheService } from '@/services/cache/cache.service';
@@ -622,6 +622,35 @@ describe('AgentChatBridge — consumeStream', () => {
expect(thread.post).toHaveBeenCalledWith(GENERIC_ERROR_MESSAGE);
});
it('names the misconfiguration when the run fails with a UserError', async () => {
const { bot, handlers } = makeBot();
const agentExecutor = {
// The real method is an async generator: the build error surfaces on
// the first `next()`, inside the stream consumer.
// eslint-disable-next-line require-yield
executeForChatPublished: vi.fn(async function* () {
throw new UserError('Credential "OpenAI" not found.');
}),
};
new AgentChatBridge(
bot as unknown as ChatBotLike,
'agent-1',
agentExecutor as never,
componentMapper,
logger,
'project-1',
bufferedIntegration,
);
const thread = makeThread();
await handlers.mention!(thread, { text: 'hi', author: { userId: 'u1', userName: 'user1' } });
expect(thread.post).toHaveBeenCalledOnce();
expect(thread.post).toHaveBeenCalledWith(
'⚠️ This agent is misconfigured: Credential "OpenAI" not found. An agent owner has to fix this in n8n.',
);
});
it('does not add a generic error when text follows an errored tool result', async () => {
const thread = await runMention(bufferedIntegration, [
erroredToolResult,
@@ -11,7 +11,7 @@ import { type HttpRequestClient, OutboundHttp } from '@n8n/backend-network';
import { Time } from '@n8n/constants';
import { Container } from '@n8n/di';
import type { Attachment, Author, Chat, Message, Thread } from 'chat';
import type { Logger } from 'n8n-workflow';
import { UserError, type Logger } from 'n8n-workflow';
import { CacheService } from '@/services/cache/cache.service';
@@ -983,7 +983,13 @@ export class AgentChatBridge {
);
return;
}
await thread.post('⚠️ Something went wrong while processing your request. Please try again.');
// A `UserError` is written for people and names the misconfiguration,
// which lets an agent owner fix it without reading server logs.
const text =
error instanceof UserError
? `⚠️ This agent is misconfigured: ${error.message} An agent owner has to fix this in n8n.`
: '⚠️ Something went wrong while processing your request. Please try again.';
await thread.post(text);
} catch (postError) {
this.logger.error('[AgentChatBridge] Failed to post error message', {
agentId: this.agentId,
@@ -63,6 +63,7 @@ const WEB_SEARCH_POLICY_INSTRUCTION =
'### Web search policy\n' +
'Use web search only on high-signal requests: explicit web/current/latest/live/recent/research/source requests, or questions that require up-to-date external facts. Do not use web search for static knowledge, uploaded knowledge, local config, codebase questions, or confirmation. Prefer answering directly or using local knowledge tools first. One search is usually enough; do not search repeatedly unless the user asks for deep research.';
/** `null` drops the tool from the agent; `undefined` falls back to the inert marker tool. */
export type ToolResolver = (
toolSchema: AgentJsonToolConfig,
) => Promise<BuiltTool | null | undefined>;
@@ -477,7 +478,9 @@ async function resolveToolRef(
options: { name: ref.name, description: ref.description },
},
};
const tool = (await options.resolveTool?.(ref)) ?? marker;
const resolved = await options.resolveTool?.(ref);
if (resolved === null) return null;
const tool = resolved ?? marker;
if (ref.requireApproval) {
return wrapToolForApproval(tool, { requireApproval: true });
}
@@ -491,7 +494,9 @@ async function resolveToolRef(
editable: false,
metadata: { nodeTool: true, ...ref.node },
};
const tool = (await options.resolveTool?.(ref)) ?? marker;
const resolved = await options.resolveTool?.(ref);
if (resolved === null) return null;
const tool = resolved ?? marker;
if (ref.requireApproval) {
return wrapToolForApproval(tool, { requireApproval: true });
}
@@ -31,11 +31,11 @@ vi.mock('@n8n/utils/sleep', () => ({ sleep: vi.fn().mockResolvedValue(undefined)
const triggerNode: INode = {
id: 'trigger-1',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
name: 'When Executed by Another Workflow',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
parameters: { inputSource: 'passthrough' },
};
const workflow = {
@@ -70,25 +70,22 @@ describe('executeWorkflow → execution classification', () => {
Container.reset();
});
it.each([
['manual', 'test'],
['integrated', 'production'],
] as const)(
'runs %s agent workflow tools as %s executions',
async (executionMode, publicMode) => {
it.each(['manual', 'integrated'] as const)(
'runs agent workflow tools as %s executions',
async (executionMode) => {
const run = vi.fn().mockResolvedValue('exec-1');
const context = {
...buildContext(run),
executionMode,
} as WorkflowToolContext;
await executeWorkflow(workflow, triggerNode, 'webhook', { body: { value: 1 } }, context);
await executeWorkflow(workflow, triggerNode, { value: 1 }, context);
const runData = run.mock.calls[0][0] as IWorkflowExecutionDataProcess;
expect(runData.executionMode).toBe(executionMode);
expect(
runData.executionData?.executionData?.nodeExecutionStack[0].data.main[0]?.[0]?.json,
).toMatchObject({ executionMode: publicMode });
).toEqual({ value: 1 });
},
);
@@ -98,9 +95,7 @@ describe('executeWorkflow → execution classification', () => {
subworkflowPolicyChecker.checkForProject.mockRejectedValue(new Error('denied'));
const context = buildContext(run, { subworkflowPolicyChecker });
await expect(executeWorkflow(workflow, triggerNode, 'manual', {}, context)).rejects.toThrow(
'denied',
);
await expect(executeWorkflow(workflow, triggerNode, {}, context)).rejects.toThrow('denied');
expect(subworkflowPolicyChecker.checkForProject).toHaveBeenCalledWith(workflow, 'p1');
expect(run).not.toHaveBeenCalled();
@@ -113,7 +108,7 @@ describe('executeWorkflow → execution classification', () => {
pinData: { 'Pinned Node': [{ json: { value: 'editor-only' } }] },
} as WorkflowEntity;
await executeWorkflow(workflowWithPinData, triggerNode, 'manual', { input: 'live' }, {
await executeWorkflow(workflowWithPinData, triggerNode, { input: 'live' }, {
...buildContext(run),
executionMode: 'integrated',
} as WorkflowToolContext);
@@ -159,7 +154,7 @@ describe('executeWorkflow → execution classification', () => {
getPostExecutePromise: vi.fn().mockResolvedValue(completedRun),
} as unknown as ActiveExecutions;
const result = await executeWorkflow(workflow, triggerNode, 'manual', {}, {
const result = await executeWorkflow(workflow, triggerNode, {}, {
...buildContext(run),
activeExecutions,
executionMode: 'integrated',
@@ -211,7 +206,6 @@ describe('executeWorkflow → execution classification', () => {
const result = await executeWorkflow(
workflow,
triggerNode,
'manual',
{},
{
...buildContext(run),
@@ -243,7 +237,6 @@ describe('executeWorkflow → eval instrumentation', () => {
await executeWorkflow(
workflow,
triggerNode,
'manual',
{ input: 'hello' },
buildContext(run, { instrumentToolAdditionalData }),
false,
@@ -264,7 +257,7 @@ describe('executeWorkflow → eval instrumentation', () => {
it('leaves the run data untouched when not instrumented', async () => {
const run = vi.fn().mockResolvedValue('exec-1');
await executeWorkflow(workflow, triggerNode, 'manual', {}, buildContext(run), false);
await executeWorkflow(workflow, triggerNode, {}, buildContext(run), false);
const runData = run.mock.calls[0][0] as IWorkflowExecutionDataProcess;
expect(runData.configureAdditionalData).toBeUndefined();
@@ -276,7 +269,6 @@ describe('executeWorkflow → eval instrumentation', () => {
await executeWorkflow(
workflow,
triggerNode,
'manual',
{},
buildContext(run, { instrumentToolAdditionalData: vi.fn() }),
false,
@@ -329,7 +321,6 @@ describe('executeWorkflow → webhook response', () => {
const result = await executeWorkflow(
workflow,
triggerNode,
'manual',
{},
buildContext(runnerResolving(relayed)),
false,
@@ -349,7 +340,6 @@ describe('executeWorkflow → webhook response', () => {
const result = await executeWorkflow(
workflow,
triggerNode,
'manual',
{},
buildContext(runnerResolving(relayed)),
false,
@@ -4,6 +4,7 @@ import { mock } from 'vitest-mock-extended';
import type { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
import { WorkflowToolUnavailableError } from '../workflow-tool-unavailable-error';
import { WorkflowToolWorkflowLoader } from '../workflow-tool-workflow-loader.service';
const reference = { workflowId: 'workflow-1', workflowName: 'Workflow' };
@@ -96,11 +97,16 @@ describe('WorkflowToolWorkflowLoader', () => {
makeWorkflow({ activeVersion: null } as unknown as Partial<WorkflowEntity>),
);
await expect(
service.loadWorkflow('project-1', reference, { usePublishedVersion: true }),
).rejects.toThrow(
'Workflow "Workflow" is not published. Publish it before using it in a production agent run.',
);
const error = await service
.loadWorkflow('project-1', reference, { usePublishedVersion: true })
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(WorkflowToolUnavailableError);
expect(error).toMatchObject({
reason: 'not_published',
message:
'Workflow "Workflow" is not published. Publish it so the published agent can use it.',
});
});
it('reads the published version from the publication service when enabled', async () => {
@@ -4,6 +4,7 @@ import {
getWorkflowToolIncompatibilityReason,
WORKFLOW_WAIT_ACTION_CANCEL,
WORKFLOW_WAIT_ACTION_CHECK,
WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME,
WORKFLOW_WAIT_SUSPEND_TYPE,
type AgentJsonToolConfig,
type SUPPORTED_WORKFLOW_TOOL_TRIGGERS,
@@ -29,13 +30,9 @@ import type {
import {
createRunExecutionData,
isTerminalExecutionStatus,
CHAT_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
MANUAL_TRIGGER_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
TimeoutExecutionCancelledError,
WAIT_INDEFINITELY,
WEBHOOK_NODE_TYPE,
} from 'n8n-workflow';
import { v4 as uuid } from 'uuid';
import { z } from 'zod';
@@ -47,6 +44,7 @@ import { WebhookResponseRelay } from '@/scaling/webhook-response-relay';
import type { WorkflowRunner } from '@/workflow-runner';
import type { InstrumentToolAdditionalData } from '../agent-runtime-instrumentation';
import { WorkflowToolUnavailableError } from './workflow-tool-unavailable-error';
import type {
WorkflowToolWorkflowLoader,
WorkflowToolWorkflowReference,
@@ -71,11 +69,7 @@ import { sanitizeToolName } from '../json-config/agent-config-composition';
* Available list can't drift.
*/
const SUPPORTED_TRIGGERS: Record<string, string> = {
[MANUAL_TRIGGER_NODE_TYPE]: 'manual',
[EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]: 'executeWorkflow',
[CHAT_TRIGGER_NODE_TYPE]: 'chat',
[FORM_TRIGGER_NODE_TYPE]: 'form',
[WEBHOOK_NODE_TYPE]: 'webhook',
};
// Compile-time check: `SUPPORTED_TRIGGERS` must cover every trigger the shared
@@ -166,8 +160,6 @@ export interface WorkflowToolContext {
* workflows (draft for test runs, published version for production).
*/
usePublishedWorkflowVersion?: boolean;
/** Base URL for webhooks/forms (e.g. http://localhost:5678/) */
webhookBaseUrl?: string;
agentId?: string;
/** Chat platform the run came from, if any. */
integrationType?: string;
@@ -205,9 +197,13 @@ export function detectTriggerNode(workflow: WorkflowEntity): DetectedTrigger {
}
}
throw new Error(
`Workflow "${workflow.name}" has no supported trigger node. ` +
`Supported triggers: ${Object.keys(SUPPORTED_TRIGGERS).join(', ')}`,
throw noSupportedTriggerError(workflow);
}
function noSupportedTriggerError(workflow: WorkflowEntity): WorkflowToolUnavailableError {
return new WorkflowToolUnavailableError(
'incompatible',
`Workflow "${workflow.name}" needs a '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' trigger to run as an agent tool.`,
);
}
@@ -228,74 +224,18 @@ export function validateCompatibility(workflow: WorkflowEntity): void {
.filter((n) => !n.disabled && incompatibility.nodeTypes.includes(n.type))
.map((n) => `${n.name} (${n.type})`)
.join(', ');
throw new Error(
throw new WorkflowToolUnavailableError(
'incompatible',
`Workflow "${workflow.name}" contains nodes that aren't supported as agent tools: ${names}. ` +
'Remove them or pick another workflow.',
);
}
// `no_supported_trigger` — surface the supported set so the message is fixable.
throw new Error(
`Workflow "${workflow.name}" has no supported trigger node. ` +
`Supported triggers: ${Object.keys(SUPPORTED_TRIGGERS).join(', ')}`,
);
throw noSupportedTriggerError(workflow);
}
// ---------------------------------------------------------------------------
// 3. normalizeTriggerInput
// ---------------------------------------------------------------------------
export function normalizeTriggerInput(
triggerNode: INode,
triggerType: string,
inputData: Record<string, unknown>,
executionMode: WorkflowToolExecutionMode,
): IPinData {
switch (triggerType) {
case 'chat':
return {
[triggerNode.name]: [
{
json: {
sessionId: `agent-${Date.now()}`,
action: 'sendMessage',
chatInput:
typeof inputData.message === 'string'
? inputData.message
: JSON.stringify(inputData),
},
},
],
};
case 'webhook': {
const { body, headers, params, query } = inputData;
return {
[triggerNode.name]: [
{
json: {
headers: isRecord(headers) ? headers : {},
params: isRecord(params) ? params : {},
query: isRecord(query) ? query : {},
body: isRecord(body) ? body : inputData,
webhookUrl: '',
executionMode: executionMode === 'manual' ? 'test' : 'production',
},
},
],
};
}
default:
// manual, executeWorkflow, and any other trigger type
return {
[triggerNode.name]: [{ json: inputData as IDataObject }],
};
}
}
// ---------------------------------------------------------------------------
// 4. inferInputSchema
// 3. inferInputSchema
// ---------------------------------------------------------------------------
/** Execute Workflow Trigger `inputSource` values (mirror nodes-base constants). */
@@ -392,17 +332,6 @@ export function inferInputSchema(
triggerType: string,
): z.ZodObject<z.ZodRawShape> {
switch (triggerType) {
case 'chat':
return z.object({ message: z.string() });
case 'manual':
return z.object({ input: z.string().optional() });
case 'form':
return z.object({
reason: z.string().optional().describe('Why the user should fill out this form'),
});
case 'executeWorkflow': {
const inputSource = getExecuteWorkflowInputSource(triggerNode);
if (inputSource === PASSTHROUGH) {
@@ -480,13 +409,12 @@ export function mergeWorkflowToolInput(
}
// ---------------------------------------------------------------------------
// 5. executeWorkflow
// 4. executeWorkflow
// ---------------------------------------------------------------------------
export async function executeWorkflow(
workflow: WorkflowEntity,
triggerNode: INode,
triggerType: string,
inputData: Record<string, unknown>,
context: WorkflowToolRunContext,
allOutputs = false,
@@ -498,12 +426,9 @@ export async function executeWorkflow(
await subworkflowPolicyChecker.checkForProject(workflow, context.projectId);
// Build pin data for the trigger
const triggerPinData = normalizeTriggerInput(
triggerNode,
triggerType,
inputData,
context.executionMode,
);
const triggerPinData: IPinData = {
[triggerNode.name]: [{ json: inputData as IDataObject }],
};
const workflowData =
workflow.pinData === undefined ? workflow : { ...workflow, pinData: undefined };
@@ -616,7 +541,7 @@ export async function executeWorkflow(
}
// ---------------------------------------------------------------------------
// 6. extractResult
// 5. extractResult
// ---------------------------------------------------------------------------
/** Map an execution's raw status into the tool's simplified status value. */
@@ -970,7 +895,7 @@ async function pollIfDueSoon(
}
// ---------------------------------------------------------------------------
// 7. resolveWorkflowTool — resolve a single workflow tool descriptor
// 6. resolveWorkflowTool — resolve a single workflow tool descriptor
// ---------------------------------------------------------------------------
export async function resolveWorkflowTool(
@@ -980,106 +905,92 @@ export async function resolveWorkflowTool(
return await buildWorkflowTool(descriptor, context);
}
/**
* Stands in for a workflow tool that cannot be built right now (workflow gone,
* unpublished, or incompatible). It keeps the configured name in the tool list and
* shares the real tool's handler, which reloads and re-validates the workflow on
* every call: the model gets the current reason instead of a bare "tool not found",
* and a workflow the user has fixed since works on the next call.
*
* Interim until AGENT-790: the runtime cache keeps this stub alive for up to 30 idle
* minutes because nothing invalidates an agent runtime when one of its workflows
* changes. Until the runtime is rebuilt the model only sees a free-form input
* schema, not the workflow's declared inputs. Once AGENT-790 invalidates the
* runtimes of dependent agents on workflow changes, a rebuilt runtime carries the
* real tool and this stub lives only while the workflow is actually broken.
*/
export function buildUnavailableWorkflowTool(
descriptor: Extract<AgentJsonToolConfig, { type: 'workflow' }>,
context: WorkflowToolContext,
): BuiltTool {
return assembleWorkflowTool(descriptor, context, {
reference: toReference(descriptor),
inputSchema: z.object({}).passthrough(),
});
}
function toReference(
descriptor: Extract<AgentJsonToolConfig, { type: 'workflow' }>,
): WorkflowToolWorkflowReference {
return {
workflowName: descriptor.workflow,
...(descriptor.workflowId !== undefined ? { workflowId: descriptor.workflowId } : {}),
};
}
async function buildWorkflowTool(
descriptor: Extract<AgentJsonToolConfig, { type: 'workflow' }>,
context: WorkflowToolContext,
): Promise<BuiltTool> {
const workflowName = descriptor.workflow;
const initialReference: WorkflowToolWorkflowReference = {
workflowName,
...(descriptor.workflowId !== undefined ? { workflowId: descriptor.workflowId } : {}),
};
const workflow = await context.workflowLoader.loadWorkflow(context.projectId, initialReference, {
usePublishedVersion: context.usePublishedWorkflowVersion === true,
});
const workflow = await context.workflowLoader.loadWorkflow(
context.projectId,
toReference(descriptor),
{
usePublishedVersion: context.usePublishedWorkflowVersion === true,
},
);
if (!workflow) {
throw new Error(`Workflow "${workflowName}" not found`);
throw new WorkflowToolUnavailableError(
'not_found',
`Workflow "${descriptor.workflow}" not found`,
);
}
validateCompatibility(workflow);
const { node: triggerNode, triggerType } = detectTriggerNode(workflow);
const fullInputSchema = inferInputSchema(triggerNode, triggerType);
return assembleWorkflowTool(descriptor, context, {
reference: { workflowId: workflow.id, workflowName: workflow.name },
inputSchema: omitFixedFieldsFromSchema(fullInputSchema, descriptor.inputs),
triggerType,
});
}
/** The handler reloads `reference` on every call, so it never relies on build-time state. */
function assembleWorkflowTool(
descriptor: Extract<AgentJsonToolConfig, { type: 'workflow' }>,
context: WorkflowToolContext,
tool: {
reference: WorkflowToolWorkflowReference;
inputSchema: z.ZodTypeAny;
triggerType?: string;
},
): BuiltTool {
const { reference, inputSchema, triggerType } = tool;
// Always run through `toToolName` even when the user supplied `descriptor.name`.
// Anthropic and OpenAI both require tool names to match `^[a-zA-Z0-9_-]{1,128}$`,
// so a workflow display name like "D&D Invite" must be sanitized before reaching
// the model. Schema validation rejects invalid names on save (see
// `agent-json-config.ts`); this is the runtime safety net for legacy configs.
const toolName = toToolName(descriptor.name ?? workflowName);
const toolDescription = descriptor.description ?? `Execute the "${workflowName}" workflow`;
const fullInputSchema = inferInputSchema(triggerNode, triggerType);
const inputSchema = omitFixedFieldsFromSchema(fullInputSchema, descriptor.inputs);
const toolName = toToolName(descriptor.name ?? descriptor.workflow);
const toolDescription = descriptor.description ?? `Execute the "${descriptor.workflow}" workflow`;
const toolInputs = descriptor.inputs;
const allOutputs = descriptor.allOutputs ?? false;
const reference: WorkflowToolWorkflowReference = {
workflowId: workflow.id,
workflowName: workflow.name,
};
// Form triggers return a link — the user fills out the form in their browser,
// and the workflow executes independently when they submit.
if (triggerType === 'form') {
const builder = new Tool(toolName)
.description(
toolDescription === `Execute the "${workflowName}" workflow`
? `Send the user a link to the "${workflowName}" form. The workflow runs automatically when they submit.`
: toolDescription,
)
.input(inputSchema)
.output(
z.object({
status: z.literal('form_link_sent'),
formUrl: z.string(),
message: z.string(),
}),
)
.toMessage(
(output) =>
({
type: 'custom',
components: [
{
type: 'section',
text: `📋 *<${output.formUrl}|Click here to open the form>*`,
},
],
}) as never,
)
.handler(async (input: Record<string, unknown>) => {
const current = await loadCurrentWorkflow(context, reference, triggerType);
const currentFullSchema = inferInputSchema(current.triggerNode, current.triggerType);
const currentSchema = omitFixedFieldsFromSchema(currentFullSchema, toolInputs);
const parsedInput = mergeWorkflowToolInput(
currentSchema.parse(input) as Record<string, unknown>,
toolInputs,
currentFullSchema,
);
const formUrl = getFormUrl(current.workflow, current.triggerNode, context.webhookBaseUrl);
const reason = parsedInput.reason;
return {
status: 'form_link_sent' as const,
formUrl,
message:
typeof reason === 'string'
? reason
: `Please fill out the ${current.workflow.name} form`,
};
});
const built = builder.build();
return {
...built,
metadata: {
kind: 'workflow',
workflowId: workflow.id,
workflowName: workflow.name,
triggerType,
},
};
}
// Standard execution-based tool for all other triggers. A body Wait node parks
// the sub-execution and hands off to the user — but only where a suspension can
// be resumed; elsewhere it reports the waiting status instead of parking forever.
// A body Wait node parks the sub-execution and hands off to the user — but only
// where a suspension can be resumed; elsewhere it reports the waiting status
// instead of parking forever.
const supportsHitl = context.supportsHitl ?? true;
const builder = new Tool(toolName)
.description(toolDescription)
@@ -1108,7 +1019,7 @@ async function buildWorkflowTool(
if (pending.success) {
result = await extractResult(pending.data.executionId, allOutputs);
} else {
current = await loadCurrentWorkflow(context, reference, triggerType);
current = await loadCurrentWorkflow(context, reference);
const currentFullSchema = inferInputSchema(current.triggerNode, current.triggerType);
const currentSchema = omitFixedFieldsFromSchema(currentFullSchema, toolInputs);
const parsedInput = mergeWorkflowToolInput(
@@ -1119,7 +1030,6 @@ async function buildWorkflowTool(
result = await executeWorkflow(
current.workflow,
current.triggerNode,
current.triggerType,
parsedInput,
{ ...context, agentRun: agentRunOf(context, ctx) },
allOutputs,
@@ -1156,7 +1066,7 @@ async function buildWorkflowTool(
if (result.status !== 'waiting' || !supportsHitl) return withoutWaitState(result);
current ??= await loadCurrentWorkflow(context, reference, triggerType);
current ??= await loadCurrentWorkflow(context, reference);
return await ctx.suspend(buildWaitCard(current.workflow.name, result.wait), {
continuation: { executionId: result.executionId },
});
@@ -1167,9 +1077,9 @@ async function buildWorkflowTool(
...built,
metadata: {
kind: 'workflow',
workflowId: workflow.id,
workflowName: workflow.name,
triggerType,
workflowId: reference.workflowId,
workflowName: reference.workflowName,
...(triggerType !== undefined && { triggerType }),
},
};
}
@@ -1177,44 +1087,23 @@ async function buildWorkflowTool(
async function loadCurrentWorkflow(
context: WorkflowToolContext,
reference: WorkflowToolWorkflowReference,
expectedTriggerType: string,
) {
const workflow = await context.workflowLoader.loadWorkflow(context.projectId, reference, {
usePublishedVersion: context.usePublishedWorkflowVersion === true,
});
if (!workflow) {
throw new Error(`Workflow "${reference.workflowName}" is no longer accessible`);
throw new WorkflowToolUnavailableError(
'not_found',
`Workflow "${reference.workflowName}" is no longer accessible`,
);
}
validateCompatibility(workflow);
const { node: triggerNode, triggerType } = detectTriggerNode(workflow);
if (triggerType !== expectedTriggerType) {
throw new Error(
`Workflow "${reference.workflowName}" changed trigger type from ${expectedTriggerType} to ${triggerType}`,
);
}
return { workflow, triggerNode, triggerType };
}
function getFormUrl(
workflow: WorkflowEntity,
triggerNode: INode,
webhookBaseUrl: string | undefined,
): string {
const directPath = triggerNode.parameters?.path;
const options: unknown = triggerNode.parameters?.options;
const optionPath = isRecord(options) ? options.path : undefined;
const formPath =
typeof directPath === 'string'
? directPath
: typeof optionPath === 'string'
? optionPath
: (triggerNode.webhookId ?? workflow.id);
const baseUrl = (webhookBaseUrl ?? 'http://localhost:5678/').replace(/\/$/, '');
return `${baseUrl}/form/${formPath}`;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -0,0 +1,19 @@
import { UserError } from 'n8n-workflow';
/**
* The referenced workflow cannot back a tool right now: it is gone, out of
* reach, not published, or does not fit the tool contract. Runtime construction
* swaps in a stub that reports the reason when called; any other error still
* fails the build.
*
* Lives apart from the factory so the runtime builder can check it without
* loading the factory eagerly.
*/
export class WorkflowToolUnavailableError extends UserError {
constructor(
readonly reason: 'not_found' | 'not_published' | 'incompatible',
message: string,
) {
super(message);
}
}
@@ -1,10 +1,11 @@
import { WorkflowsConfig } from '@n8n/config';
import { type WorkflowEntity, WorkflowRepository } from '@n8n/db';
import { Service } from '@n8n/di';
import { UserError } from 'n8n-workflow';
import { WorkflowPublishedDataService } from '@/workflows/workflow-published-data.service';
import { WorkflowToolUnavailableError } from './workflow-tool-unavailable-error';
export interface WorkflowToolWorkflowReference {
workflowId?: string;
workflowName: string;
@@ -14,7 +15,8 @@ export interface LoadWorkflowOptions {
/**
* Load the published workflow version instead of the draft. Set for
* production agent runs, mirroring how sub-workflows resolve referenced
* workflows. Throws when the workflow has never been published.
* workflows. Throws `WorkflowToolUnavailableError` when the workflow has
* never been published.
*/
usePublishedVersion?: boolean;
}
@@ -42,8 +44,9 @@ export class WorkflowToolWorkflowLoader {
if (options.usePublishedVersion) {
const published = await this.resolvePublishedContent(workflow);
if (!published) {
throw new UserError(
`Workflow "${workflow.name}" is not published. Publish it before using it in a production agent run.`,
throw new WorkflowToolUnavailableError(
'not_published',
`Workflow "${workflow.name}" is not published. Publish it so the published agent can use it.`,
);
}
Object.assign(workflow, { nodes: published.nodes, connections: published.connections });
@@ -1,4 +1,5 @@
import { AgentJsonConfigBaseSchema } from '@n8n/api-types';
import { AgentJsonConfigBaseSchema, WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME } from '@n8n/api-types';
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import { zodToJsonSchema } from 'zod-to-json-schema';
export const AGENT_BUILDER_REFERENCE_URI = 'n8n://agents/reference';
@@ -128,6 +129,10 @@ Tool references use these forms:
- Custom tool: { "type": "custom", "id": "tool_name" }
- Workflow tool: { "type": "workflow", "workflow": "Workflow Name", "name": "tool_name" }
A workflow tool must start with a '${WORKFLOW_TOOL_TRIGGER_DISPLAY_NAME}' trigger
(${EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE}) and must be published before the published Agent can
call it; validate_agent reports incompatible_reference with reason no_supported_trigger or
not_published otherwise.
- Node tool: { "type": "node", "name": "tool_name", "node": { "nodeType": "...",
"nodeTypeVersion": 1, "nodeParameters": {}, "credentials": {} } }
@@ -7656,7 +7656,8 @@
"agents.tools.workflow.incompatible.title": "Workflow is not compatible",
"agents.tools.workflow.incompatible.message": "Workflow \"{name}\" contains nodes that can't be used as an agent tool: {nodes}.",
"agents.tools.workflow.disabled.incompatibleNodes": "Contains nodes that aren't supported as agent tools (Wait, Form)",
"agents.tools.workflow.disabled.noSupportedTrigger": "No supported trigger node",
"agents.tools.workflow.disabled.noSupportedTrigger": "Needs a '{trigger}' trigger",
"agents.tools.workflow.notPublished": "Not published",
"agents.tools.workflow.createFailed.title": "Couldn't create workflow",
"agents.tools.workflow.createFailed.message": "Try creating the workflow again.",
"agents.tools.workflow.fetchFailed.title": "Couldn't check workflow compatibility",
@@ -7670,17 +7671,23 @@
"agents.toolConfig.workflow.allOutputs": "Return all node outputs",
"agents.toolConfig.workflow.allOutputs.hint": "When off, only the last node's output is returned.",
"agents.toolConfig.workflow.target": "Workflow",
"agents.toolConfig.workflow.target.notice": "This tool calls the workflow selected below and returns its output. Only workflows that start with a supported trigger, such as Execute Workflow or Chat, can be used.",
"agents.toolConfig.workflow.target.notice": "This tool calls the workflow selected below and returns its output. Only workflows that start with a '{trigger}' trigger can be used. The workflow must be published before the published agent can call it.",
"agents.toolConfig.workflow.target.notPublished": "This workflow is not published. Publish it to use it in the published agent.",
"agents.toolConfig.workflow.target.unavailable": "Workflow \"{name}\" isn't available. It may have been deleted, moved, or you may not have access. Select another workflow.",
"agents.toolConfig.workflow.target.placeholder": "Select a workflow",
"agents.toolConfig.workflow.target.duplicateName": "More than one workflow in this project is called \"{name}\". The agent may call either one.",
"agents.toolConfig.workflow.target.idNotFound": "No workflow with this ID can be used here. It has to be in this project and start with a supported trigger.",
"agents.toolConfig.workflow.target.idNotFound": "No workflow with this ID can be used here. It has to be in this project and start with a '{trigger}' trigger.",
"agents.toolConfig.workflow.target.open": "Open workflow in a new tab",
"agents.toolConfig.workflow.inputs": "Workflow Inputs",
"agents.toolConfig.workflow.inputs.hint": "Choose which fields the agent fills in and which stay fixed.",
"agents.toolConfig.workflow.inputs": "Workflow inputs",
"agents.toolConfig.workflow.inputs.hint": "The agent fills in these fields when it calls the workflow. Set a fixed value for any field the agent should not choose.",
"agents.toolConfig.workflow.inputs.mode.ai": "Determined by agent",
"agents.toolConfig.workflow.inputs.mode.fixed": "Fixed value",
"agents.toolConfig.workflow.inputs.value.placeholder": "Enter a fixed value",
"agents.toolConfig.workflow.inputs.value.placeholder.string": "Enter text",
"agents.toolConfig.workflow.inputs.value.placeholder.number": "Enter a number",
"agents.toolConfig.workflow.inputs.value.placeholder.boolean": "true or false",
"agents.toolConfig.workflow.inputs.value.placeholder.array": "Enter a JSON array, e.g. [\"a\", \"b\"]",
"agents.toolConfig.workflow.inputs.value.placeholder.object": "Enter a JSON object, e.g. {'{'}\"key\": \"value\"{'}'}",
"agents.toolConfig.save": "Save",
"agents.toolConfig.cancel": "Cancel",
"agents.toolConfig.approval.label": "Require approval",
@@ -8051,7 +8058,8 @@
"agents.builder.validation.issue.tool.workflow.missingReference": "Workflow \"{id}\" can't be found. Select another workflow.",
"agents.builder.validation.issue.tool.workflow.incompatibleReference": "Workflow \"{id}\" can't be used as an agent tool",
"agents.builder.validation.issue.tool.workflow.incompatibleNodes": "Workflow \"{id}\" contains nodes that aren't supported as agent tools (e.g. Wait, Form). Remove them or pick another workflow.",
"agents.builder.validation.issue.tool.workflow.noSupportedTrigger": "Workflow \"{id}\" has no supported trigger node. Add a Manual, Chat, Webhook, Form, or Execute Workflow Trigger.",
"agents.builder.validation.issue.tool.workflow.noSupportedTrigger": "Workflow \"{id}\" has no supported trigger. Replace the trigger with '{trigger}'.",
"agents.builder.validation.issue.tool.workflow.notPublished": "Workflow \"{id}\" is not published. Publish this workflow to use it in the published agent.",
"agents.builder.validation.issue.tool.custom.missingReference": "This tool's code is missing",
"agents.builder.validation.issue.tool.node.missingReference": "This node type isn't available on this instance",
"agents.builder.validation.issue.mcpServer.incompatibleCredential": "The credential type doesn't match the server's authentication method",
@@ -1028,6 +1028,33 @@ describe('AgentCapabilitiesSection', () => {
);
});
it('marks an unpublished workflow tool as a warning, not as invalid', async () => {
const tools: AgentJsonToolRef[] = [
{ type: 'workflow', workflowId: 'wf-1', workflow: 'Draft Flow' },
];
const wrapper = mountSection(tools, {}, null, [], [], {
validationIssues: [
{
code: 'incompatible_reference',
path: 'tools.0.workflowId',
capability: { kind: 'tool', id: 'Draft Flow', index: 0, toolType: 'workflow' },
reason: 'not_published',
},
],
});
await flushPromises();
const chip = wrapper.find('[data-testid="agent-capabilities-tool-row"]');
expect(chip.classes().some((c) => c.includes('warning'))).toBe(true);
expect(chip.classes().some((c) => c.includes('invalid'))).toBe(false);
expect(wrapper.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="agent-chip-invalid-icon"]').exists()).toBe(false);
expect(chip.find('[data-testid="stub-tooltip-content"]').text()).toContain(
'agents.builder.validation.issue.tool.workflow.notPublished',
);
});
it('leaves capability chips unmarked when there are no matching validation issues', () => {
const tools: AgentJsonToolRef[] = [
{
@@ -675,7 +675,9 @@ describe('AgentToolsConnectionModalWrapper', () => {
expect(noTriggerDisabled).toBeTruthy();
expect(noTriggerDisabled?.disabled).toBe(true);
expect(noTriggerDisabled?.disabledReason).toContain('No supported trigger node');
expect(noTriggerDisabled?.disabledReason).toContain(
"Needs a 'When Executed by Another Workflow' trigger",
);
// Disabled items appear after compatible ones within the category.
const workflowItems = items.filter((i) => i.kind === 'workflow');
@@ -65,6 +65,7 @@ function workflow(
overrides: {
id?: string;
isArchived?: boolean;
activeVersionId?: string | null;
updatedAt?: string;
description?: string;
} = {},
@@ -74,6 +75,7 @@ function workflow(
name,
description: overrides.description ?? '',
isArchived: overrides.isArchived ?? false,
activeVersionId: overrides.activeVersionId === undefined ? 'v-1' : overrides.activeVersionId,
updatedAt: overrides.updatedAt ?? '2026-07-01T12:00:00.000Z',
nodes: [{ type: TRIGGER_TYPE }] as Array<{
type: string;
@@ -341,6 +343,17 @@ describe('WorkflowToolConfigContent', () => {
expect(queryByTestId('agent-workflow-tool-target-missing')).toBeNull();
});
it('flags a usable target whose workflow is not published', async () => {
setProjectWorkflows([workflow('Notify Sales', { activeVersionId: null })]);
const { queryByTestId, findByTestId } = renderComponent({
props: { initialRef: createRef() },
});
expect(await findByTestId('agent-workflow-tool-target-unpublished')).toBeTruthy();
expect(queryByTestId('agent-workflow-tool-target-unusable')).toBeNull();
});
describe('workflow input bindings', () => {
function workflowWithInputs(
name: string,
@@ -35,6 +35,9 @@ type ToolRowBase = {
invalid: boolean;
/** Human-readable reasons behind `invalid`; the union of member reasons for a grouped row. */
invalidReasons: string[];
/** True when the row blocks publishing but still works in preview (e.g. an unpublished workflow). */
warning: boolean;
warningReasons: string[];
};
export type GroupedToolRow = ToolRowBase & {
@@ -21,6 +21,8 @@ type BaseToolRow = {
openTarget: ToolOpenTarget;
invalid: boolean;
invalidReasons: string[];
warning: boolean;
warningReasons: string[];
};
function toUngroupedToolRow(row: BaseToolRow): ToolRow {
@@ -41,6 +43,8 @@ function toUngroupedToolRow(row: BaseToolRow): ToolRow {
fallbackIcon: row.fallbackIcon,
invalid: row.invalid,
invalidReasons: row.invalidReasons,
warning: row.warning,
warningReasons: row.warningReasons,
isGrouped: false,
tool: item,
};
@@ -57,6 +61,8 @@ function toGroupedToolRow(group: BaseToolRow[]): GroupedToolRow {
fallbackIcon: first.fallbackIcon,
invalid: group.some((row) => row.invalid),
invalidReasons: [...new Set(group.flatMap((row) => row.invalidReasons))],
warning: group.some((row) => row.warning),
warningReasons: [...new Set(group.flatMap((row) => row.warningReasons))],
isGrouped: true,
tools: group.map((row) => ({
index: row.index,
@@ -17,6 +17,8 @@ import { useProjectAgentsList } from '../composables/useProjectAgentsList';
import { toolRefToNode } from '../composables/useAgentToolRefAdapter';
import { AGENT_SUB_AGENTS_MODAL_KEY, AGENT_TASK_MODAL_KEY } from '../constants';
import { formatToolNameForDisplay } from '../utils/toolDisplayName';
import { isWarningIssue } from '../utils/validationIssues';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
import type { ToolMenuItem, ToolOpenTarget, ToolRow } from './AgentCapabilitiesSection.types';
import { buildToolRows } from './AgentCapabilitiesSection.utils';
import AgentChipButton from './AgentChipButton.vue';
@@ -184,6 +186,7 @@ const REASON_SPECIFIC_KEYS: Record<string, BaseTextKey> = {
'agents.builder.validation.issue.tool.workflow.incompatibleNodes' as BaseTextKey,
no_supported_trigger:
'agents.builder.validation.issue.tool.workflow.noSupportedTrigger' as BaseTextKey,
not_published: 'agents.builder.validation.issue.tool.workflow.notPublished' as BaseTextKey,
};
function issueMessage(issue: AgentConfigValidationIssue): string {
@@ -195,7 +198,9 @@ function issueMessage(issue: AgentConfigValidationIssue): string {
: undefined) ??
SPECIFIC_ISSUE_KEYS[`${kind}.${issue.code}`] ??
GENERIC_ISSUE_KEYS[issue.code];
return i18n.baseText(key, { interpolate: { id: id ?? '' } });
return i18n.baseText(key, {
interpolate: { id: id ?? '', trigger: workflowToolTriggerLabel() },
});
}
function issueMessages(issues: AgentConfigValidationIssue[]): string[] {
@@ -210,9 +215,11 @@ function issuesFor(kind: AgentConfigValidationIssue['capability']['kind']) {
function groupIssueMessages<TKey>(
kind: AgentConfigValidationIssue['capability']['kind'],
keyOf: (issue: AgentConfigValidationIssue) => TKey | undefined,
include: (issue: AgentConfigValidationIssue) => boolean = () => true,
): Map<TKey, string[]> {
const byKey = new Map<TKey, AgentConfigValidationIssue[]>();
for (const issue of issuesFor(kind)) {
if (!include(issue)) continue;
const key = keyOf(issue);
if (key === undefined) continue;
const existing = byKey.get(key);
@@ -222,8 +229,17 @@ function groupIssueMessages<TKey>(
return new Map([...byKey].map(([key, issues]) => [key, issueMessages(issues)]));
}
// Warnings (an unpublished workflow) render orange and leave the preview usable;
// everything else is a red error.
const toolIssueMessages = computed(() =>
groupIssueMessages('tool', (issue) => issue.capability.index),
groupIssueMessages(
'tool',
(issue) => issue.capability.index,
(issue) => !isWarningIssue(issue),
),
);
const toolWarningMessages = computed(() =>
groupIssueMessages('tool', (issue) => issue.capability.index, isWarningIssue),
);
const mcpServerIssueMessages = computed(() =>
groupIssueMessages('mcpServer', (issue) => issue.capability.id),
@@ -409,6 +425,8 @@ const toolRows = computed<ToolRow[]>(() => {
capabilityTools.value.map((entry) => {
const nodeType = toolNodeType(entry);
const reasons = toolEntryReasons(entry);
const warningReasons =
entry.kind === 'tool' ? (toolWarningMessages.value.get(entry.index) ?? []) : [];
return {
index: entry.index,
label: toolLabel(entry),
@@ -419,6 +437,8 @@ const toolRows = computed<ToolRow[]>(() => {
openTarget: entry.openTarget,
invalid: reasons.length > 0,
invalidReasons: reasons,
warning: warningReasons.length > 0,
warningReasons,
};
}),
);
@@ -566,6 +586,8 @@ function openExistingSubAgentModal(subAgent: {
<AgentChipButton
:invalid="tool.invalid"
:invalid-reasons="tool.invalidReasons"
:warning="tool.warning"
:warning-reasons="tool.warningReasons"
:disabled="props.disabled"
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
@@ -610,6 +632,8 @@ function openExistingSubAgentModal(subAgent: {
v-else-if="tool.nodeType"
:invalid="tool.invalid"
:invalid-reasons="tool.invalidReasons"
:warning="tool.warning"
:warning-reasons="tool.warningReasons"
:disabled="props.disabled"
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
@@ -625,6 +649,8 @@ function openExistingSubAgentModal(subAgent: {
:icon="tool.fallbackIcon"
:invalid="tool.invalid"
:invalid-reasons="tool.invalidReasons"
:warning="tool.warning"
:warning-reasons="tool.warningReasons"
:disabled="props.disabled"
:class="$style.capabilityChip"
data-testid="agent-capabilities-tool-row"
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { N8nIcon, N8nText, N8nTooltip } from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
@@ -12,6 +13,10 @@ const props = withDefaults(
invalid?: boolean;
/** Human-readable reasons behind `invalid`, shown in a tooltip on the warning icon. */
invalidReasons?: string[];
/** Marks the chip as usable in preview but blocking publish (e.g. an unpublished workflow). */
warning?: boolean;
/** Human-readable reasons behind `warning`; ignored while `invalid` is set. */
warningReasons?: string[];
clickable?: boolean;
}>(),
{
@@ -20,10 +25,14 @@ const props = withDefaults(
active: false,
invalid: false,
invalidReasons: () => [],
warning: false,
warningReasons: () => [],
clickable: true,
},
);
const reasons = computed(() => (props.invalid ? props.invalidReasons : props.warningReasons));
defineSlots<{
icon?: () => unknown;
default?: () => unknown;
@@ -43,6 +52,7 @@ const emit = defineEmits<{
{
[$style.active]: props.active,
[$style.invalid]: props.invalid,
[$style.warning]: props.warning && !props.invalid,
[$style.nonClickable]: !props.clickable,
},
]"
@@ -62,15 +72,19 @@ const emit = defineEmits<{
<N8nText size="small" color="text-dark" :class="$style.text">
<slot />
</N8nText>
<N8nTooltip v-if="props.invalid" :disabled="props.invalidReasons.length === 0" placement="top">
<N8nTooltip
v-if="props.invalid || props.warning"
:disabled="reasons.length === 0"
placement="top"
>
<N8nIcon
icon="triangle-alert"
:size="14"
:class="$style.invalidIcon"
data-testid="agent-chip-invalid-icon"
:class="[$style.alertIcon, { [$style.warningIcon]: !props.invalid }]"
:data-testid="props.invalid ? 'agent-chip-invalid-icon' : 'agent-chip-warning-icon'"
/>
<template #content>
<div v-for="reason in props.invalidReasons" :key="reason">{{ reason }}</div>
<div v-for="reason in reasons" :key="reason">{{ reason }}</div>
</template>
</N8nTooltip>
</button>
@@ -109,10 +123,18 @@ const emit = defineEmits<{
border-color: var(--canvas-node--border-color--error, var(--color--danger));
}
.invalidIcon {
.warning {
border-color: var(--color--warning);
}
.alertIcon {
flex-shrink: 0;
}
.warningIcon {
color: var(--color--warning);
}
.nonClickable {
pointer-events: none;
}
@@ -71,6 +71,7 @@ import {
import type { AgentJsonMcpServerConfig, AgentJsonToolRef, WorkflowToolRef } from '../types';
import type { WorkflowToolIncompatibilityReason } from '@n8n/api-types';
import { toToolIconSource } from '../utils/toolIconSource';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
const BASE_CATEGORIES: ToolCategoryKey[] = ['all', 'mcp', 'n8n', 'app-action', 'workflows'];
/** Prefix for the synthetic ids of gateway-backed rows in the n8n Connect section. */
@@ -699,6 +700,12 @@ function availableWorkflowItem(workflow: IWorkflowDb): WorkflowConnectionItem {
workflowId: workflow.id,
title: workflow.name,
description: workflow.description ?? undefined,
// An unpublished workflow stays selectable; the warning tells the user the
// published agent cannot call it until they publish it.
warning:
workflow.activeVersionId === null
? i18n.baseText('agents.tools.workflow.notPublished')
: undefined,
status: 'none',
credentials: [],
};
@@ -726,7 +733,9 @@ function disabledWorkflowReasonText(reason: WorkflowToolIncompatibilityReason):
if (reason.reason === 'incompatible_nodes') {
return i18n.baseText('agents.tools.workflow.disabled.incompatibleNodes');
}
return i18n.baseText('agents.tools.workflow.disabled.noSupportedTrigger');
return i18n.baseText('agents.tools.workflow.disabled.noSupportedTrigger', {
interpolate: { trigger: workflowToolTriggerLabel() },
});
}
/**
@@ -4,6 +4,9 @@ import { N8nTooltip } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed } from 'vue';
import { isWarningIssue } from '../utils/validationIssues';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
const props = withDefaults(
defineProps<{
disabled: boolean;
@@ -51,6 +54,7 @@ const REASON_SPECIFIC_KEYS: Record<string, BaseTextKey> = {
'agents.builder.validation.issue.tool.workflow.incompatibleNodes' as BaseTextKey,
no_supported_trigger:
'agents.builder.validation.issue.tool.workflow.noSupportedTrigger' as BaseTextKey,
not_published: 'agents.builder.validation.issue.tool.workflow.notPublished' as BaseTextKey,
};
const CORE_PATH_KEYS: Record<string, BaseTextKey> = {
@@ -72,6 +76,7 @@ const CAPABILITY_KEYS: Record<AgentCapabilityKind, BaseTextKey> = {
function isPreviewIssue(issue: AgentConfigValidationIssue): boolean {
if (issue.capability.kind === 'channel' || issue.capability.kind === 'task') return false;
if (isWarningIssue(issue)) return false;
// Fixed URLs are required for publishing, but the draft preview can still run.
return !(issue.code === 'invalid_value' && issue.path.endsWith('.node.nodeParameters.url'));
@@ -94,7 +99,9 @@ function issueMessage(issue: AgentConfigValidationIssue): string {
: undefined) ??
SPECIFIC_ISSUE_KEYS[`${kind}.${issue.code}`] ??
GENERIC_ISSUE_KEYS[issue.code];
const message = i18n.baseText(key, { interpolate: { id: id ?? '' } });
const message = i18n.baseText(key, {
interpolate: { id: id ?? '', trigger: workflowToolTriggerLabel() },
});
return `${capabilityLabel(issue)}: ${message}`;
}
@@ -28,6 +28,7 @@ import {
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { BaseTextKey } from '@n8n/i18n';
import type { AgentJsonWorkflowToolInputField } from '@n8n/api-types';
import { useRouter } from 'vue-router';
@@ -39,6 +40,7 @@ import {
listWorkflowToolInputFields,
parseWorkflowToolFixedValue,
} from '../utils/workflowToolInputFields';
import { workflowToolTriggerLabel } from '../utils/workflowToolTriggers';
const props = defineProps<{
initialRef: WorkflowToolRef;
@@ -51,6 +53,7 @@ const emit = defineEmits<{
}>();
const i18n = useI18n();
const triggerLabel = workflowToolTriggerLabel();
const router = useRouter();
const { availableWorkflows, projectWorkflows, loadWorkflows } = useAgentToolCatalog();
@@ -146,6 +149,14 @@ const isAmbiguous = computed(
() => workflowId.value === undefined && matchingProjectWorkflows.value.length > 1,
);
/** Target works in preview, but the published agent cannot call it until it is published. */
const isUnpublished = computed(
() =>
!isLoadingWorkflows.value &&
!isUnusable.value &&
targetWorkflow.value?.activeVersionId === null,
);
/**
* Options are keyed by id so same-named workflows remain individually
* selectable.
@@ -189,6 +200,23 @@ function fieldFixedValue(fieldName: string): string {
return formatWorkflowToolFixedValue(binding.value);
}
// The trigger's declared field type only matters once the user types a fixed
// value, so it surfaces as the input placeholder instead of a label.
const FIXED_VALUE_PLACEHOLDER_KEYS: Record<string, BaseTextKey> = {
string: 'agents.toolConfig.workflow.inputs.value.placeholder.string',
number: 'agents.toolConfig.workflow.inputs.value.placeholder.number',
boolean: 'agents.toolConfig.workflow.inputs.value.placeholder.boolean',
array: 'agents.toolConfig.workflow.inputs.value.placeholder.array',
object: 'agents.toolConfig.workflow.inputs.value.placeholder.object',
};
function fieldFixedValuePlaceholder(fieldName: string): string {
return i18n.baseText(
FIXED_VALUE_PLACEHOLDER_KEYS[fieldType(fieldName) ?? ''] ??
'agents.toolConfig.workflow.inputs.value.placeholder',
);
}
// Raw text being typed per field. While editing, the input shows this
// uncoerced text so fractional numbers (and in-progress JSON) survive
// each keystroke. On blur the text is parsed and committed to `inputs`.
@@ -349,7 +377,11 @@ defineExpose({
</div>
<N8nCallout theme="warning" data-test-id="agent-workflow-tool-target-notice">
{{ i18n.baseText('agents.toolConfig.workflow.target.notice') }}
{{
i18n.baseText('agents.toolConfig.workflow.target.notice', {
interpolate: { trigger: triggerLabel },
})
}}
</N8nCallout>
<div :class="$style.field">
@@ -357,7 +389,7 @@ defineExpose({
{{ i18n.baseText('agents.toolConfig.workflow.target') }}
<N8nText color="primary" size="small" bold>*</N8nText>
</label>
<div :class="$style.targetRow">
<div :class="$style.controlRow">
<N8nSelect
:model-value="mode"
:class="$style.modeSelector"
@@ -372,7 +404,7 @@ defineExpose({
v-if="mode === 'list'"
id="workflow-tool-target"
:model-value="selectedOptionId"
:class="$style.targetInput"
:class="$style.controlInput"
filterable
:loading="isLoadingWorkflows"
:placeholder="i18n.baseText('agents.toolConfig.workflow.target.placeholder')"
@@ -404,7 +436,7 @@ defineExpose({
v-else
id="workflow-tool-target"
v-model="enteredId"
:class="$style.targetInput"
:class="$style.controlInput"
:placeholder="i18n.baseText('resourceLocator.id.placeholder')"
data-test-id="agent-workflow-tool-target-id"
@blur="handleEnterWorkflowId(enteredId)"
@@ -429,7 +461,11 @@ defineExpose({
color="danger"
data-test-id="agent-workflow-tool-target-id-unresolvable"
>
{{ i18n.baseText('agents.toolConfig.workflow.target.idNotFound') }}
{{
i18n.baseText('agents.toolConfig.workflow.target.idNotFound', {
interpolate: { trigger: triggerLabel },
})
}}
</N8nText>
<N8nText
v-else-if="isMissing"
@@ -467,6 +503,14 @@ defineExpose({
})
}}
</N8nText>
<N8nText
v-else-if="isUnpublished"
size="xsmall"
color="warning"
data-test-id="agent-workflow-tool-target-unpublished"
>
{{ i18n.baseText('agents.toolConfig.workflow.target.notPublished') }}
</N8nText>
</div>
<div
@@ -480,40 +524,41 @@ defineExpose({
<N8nText size="xsmall" color="text-light">
{{ i18n.baseText('agents.toolConfig.workflow.inputs.hint') }}
</N8nText>
<div
v-for="field in declaredInputFields"
:key="field.name"
:class="$style.inputRow"
:data-test-id="`agent-workflow-tool-input-${field.name}`"
>
<div :class="$style.inputFieldOption">
<div :class="$style.inputFields">
<div
v-for="field in declaredInputFields"
:key="field.name"
:class="$style.field"
:data-test-id="`agent-workflow-tool-input-${field.name}`"
>
<N8nText size="small" :bold="true" :class="$style.inputName">{{ field.name }}</N8nText>
<N8nSelect
:model-value="fieldMode(field.name)"
:class="$style.inputMode"
:data-test-id="`agent-workflow-tool-input-mode-${field.name}`"
@update:model-value="setFieldMode(field.name, $event)"
>
<N8nOption
value="ai"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.ai')"
<div :class="$style.controlRow">
<N8nSelect
:model-value="fieldMode(field.name)"
:class="fieldMode(field.name) === 'fixed' ? $style.inputMode : $style.controlInput"
:data-test-id="`agent-workflow-tool-input-mode-${field.name}`"
@update:model-value="setFieldMode(field.name, $event)"
>
<N8nOption
value="ai"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.ai')"
/>
<N8nOption
value="fixed"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.fixed')"
/>
</N8nSelect>
<N8nInput
v-if="fieldMode(field.name) === 'fixed'"
:model-value="fieldInputDisplay(field.name)"
:class="$style.controlInput"
:placeholder="fieldFixedValuePlaceholder(field.name)"
:data-test-id="`agent-workflow-tool-input-value-${field.name}`"
@update:model-value="handleFieldInput(field.name, $event)"
@blur="commitFieldFixedValue(field.name)"
/>
<N8nOption
value="fixed"
:label="i18n.baseText('agents.toolConfig.workflow.inputs.mode.fixed')"
/>
</N8nSelect>
</div>
</div>
<N8nInput
v-if="fieldMode(field.name) === 'fixed'"
:model-value="fieldInputDisplay(field.name)"
:class="$style.inputValue"
:placeholder="i18n.baseText('agents.toolConfig.workflow.inputs.value.placeholder')"
:data-test-id="`agent-workflow-tool-input-value-${field.name}`"
@update:model-value="handleFieldInput(field.name, $event)"
@blur="commitFieldFixedValue(field.name)"
/>
</div>
</div>
@@ -572,7 +617,7 @@ defineExpose({
min-width: 0;
}
.targetRow {
.controlRow {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
@@ -583,7 +628,7 @@ defineExpose({
width: 120px;
}
.targetInput {
.controlInput {
flex: 1;
min-width: 0;
}
@@ -615,35 +660,18 @@ defineExpose({
text-overflow: ellipsis;
}
.inputRow {
.inputFields {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
flex-wrap: wrap;
flex-direction: row;
flex-direction: column;
gap: var(--spacing--xs);
padding-top: var(--spacing--3xs);
}
.inputName {
flex: 0 0 120px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.inputFieldOption {
display: flex;
align-items: center;
gap: var(--spacing--3xs);
flex-wrap: wrap;
width: 100%;
text-transform: capitalize;
}
.inputMode {
flex: 0 0 200px;
}
.inputValue {
flex: 1;
min-width: 120px;
flex: 0 0 180px;
}
</style>
@@ -81,6 +81,16 @@ const HIDDEN_CHAT_TOOL = makeNodeType({
displayName: 'Chat Tool',
});
const executeWorkflowTrigger = {
id: 't',
name: 'When Executed by Another Workflow',
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0] as [number, number],
parameters: {},
};
const TRIGGER_NAME = executeWorkflowTrigger.name;
function makeWorkflow(overrides: Partial<IWorkflowDb> = {}): IWorkflowDb {
return {
id: 'wf-1',
@@ -92,16 +102,7 @@ function makeWorkflow(overrides: Partial<IWorkflowDb> = {}): IWorkflowDb {
updatedAt: '2026-01-02T00:00:00Z',
versionId: 'v-1',
activeVersionId: null,
nodes: [
{
id: 't',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
nodes: [executeWorkflowTrigger],
connections: {},
...overrides,
} as IWorkflowDb;
@@ -196,18 +197,10 @@ describe('useAgentToolCatalog', () => {
it('loads and filters project workflows for agent-tool compatibility', async () => {
const compatible = makeWorkflow({ id: 'ok' });
const archived = makeWorkflow({ id: 'archived', isArchived: true });
const manualTrigger = {
id: 't',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
const formBody = makeWorkflow({
id: 'form',
nodes: [
manualTrigger,
executeWorkflowTrigger,
{
id: 'f',
name: 'Form',
@@ -218,14 +211,14 @@ describe('useAgentToolCatalog', () => {
},
],
// Form is reachable from the trigger, so it actually runs and must be flagged.
connections: { 'Manual Trigger': { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
connections: { [TRIGGER_NAME]: { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
});
// A reachable Wait node no longer blocks a workflow: the tool hands its
// suspension off to HITL, so this one is selectable.
const waitBody = makeWorkflow({
id: 'wait',
nodes: [
manualTrigger,
executeWorkflowTrigger,
{
id: 'w',
name: 'Wait',
@@ -235,7 +228,7 @@ describe('useAgentToolCatalog', () => {
parameters: {},
},
],
connections: { 'Manual Trigger': { main: [[{ node: 'Wait', type: 'main', index: 0 }]] } },
connections: { [TRIGGER_NAME]: { main: [[{ node: 'Wait', type: 'main', index: 0 }]] } },
});
const noTrigger = makeWorkflow({
id: 'no-trigger',
@@ -250,9 +243,23 @@ describe('useAgentToolCatalog', () => {
},
],
});
// Only the execute-workflow trigger is supported; a manual trigger is not.
const manualOnly = makeWorkflow({
id: 'manual',
nodes: [
{
id: 'm',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
});
workflowsListStore.searchWorkflows = vi
.fn()
.mockResolvedValue([compatible, archived, formBody, waitBody, noTrigger]);
.mockResolvedValue([compatible, archived, formBody, waitBody, noTrigger, manualOnly]);
const { availableWorkflows, incompatibleWorkflows, loadWorkflows } = useAgentToolCatalog();
await loadWorkflows('p-1');
@@ -272,6 +279,10 @@ describe('useAgentToolCatalog', () => {
workflow: expect.objectContaining({ id: 'no-trigger' }),
reason: { reason: 'no_supported_trigger' },
},
{
workflow: expect.objectContaining({ id: 'manual' }),
reason: { reason: 'no_supported_trigger' },
},
]);
});
});
@@ -295,82 +306,36 @@ describe('isWorkflowCompatibleWithAgentTools', () => {
}),
),
).toBe(false);
const formNode = {
id: 'f',
name: 'Form',
type: 'n8n-nodes-base.form',
typeVersion: 1,
position: [0, 0] as [number, number],
parameters: {},
};
const triggerToForm: IWorkflowDb['connections'] = {
[TRIGGER_NAME]: { main: [[{ node: 'Form', type: 'main', index: 0 }]] },
};
// An incompatible node reachable from the trigger blocks the workflow.
expect(
isWorkflowCompatibleWithAgentTools(
makeWorkflow({
nodes: [
{
id: 't',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'f',
name: 'Form',
type: 'n8n-nodes-base.form',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
connections: { 'Manual Trigger': { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
}),
makeWorkflow({ nodes: [executeWorkflowTrigger, formNode], connections: triggerToForm }),
),
).toBe(false);
// An incompatible node that is NOT reachable from the trigger never runs,
// so it must not block the workflow.
expect(
isWorkflowCompatibleWithAgentTools(
makeWorkflow({
nodes: [
{
id: 't',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'f',
name: 'Form',
type: 'n8n-nodes-base.form',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
],
}),
makeWorkflow({ nodes: [executeWorkflowTrigger, formNode] }),
),
).toBe(true);
// A disabled incompatible node never runs, so it must not block the workflow.
expect(
isWorkflowCompatibleWithAgentTools(
makeWorkflow({
nodes: [
{
id: 't',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: [0, 0],
parameters: {},
},
{
id: 'f',
name: 'Form',
type: 'n8n-nodes-base.form',
typeVersion: 1,
position: [0, 0],
parameters: {},
disabled: true,
},
],
connections: { 'Manual Trigger': { main: [[{ node: 'Form', type: 'main', index: 0 }]] } },
nodes: [executeWorkflowTrigger, { ...formNode, disabled: true }],
connections: triggerToForm,
}),
),
).toBe(true);
@@ -147,10 +147,20 @@ export function useAgentToolCatalog() {
// Fetch all project workflows (not just those with a supported trigger)
// so unsupported ones can be shown greyed-out with a reason in the picker.
// `connections` is needed to scope the incompatibility check to nodes
// actually reachable from a supported trigger.
// actually reachable from a supported trigger. A null `activeVersionId`
// marks unpublished workflows, which the published agent cannot call yet.
projectWorkflows.value = await workflowsListStore.searchWorkflows({
projectId,
select: ['id', 'name', 'description', 'isArchived', 'nodes', 'connections', 'updatedAt'],
select: [
'id',
'name',
'description',
'isArchived',
'activeVersionId',
'nodes',
'connections',
'updatedAt',
],
});
} catch (error) {
console.warn('[useAgentToolCatalog] failed to load workflows for project', error);
@@ -1,9 +1,8 @@
import { describe, expect, it } from 'vitest';
import { CHAT_TRIGGER_NODE_TYPE, EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
import type { IWorkflowDb } from '@/Interface';
import {
detectWorkflowToolTrigger,
formatWorkflowToolFixedValue,
listWorkflowToolInputFields,
parseWorkflowToolFixedValue,
@@ -32,29 +31,6 @@ function workflowWithTrigger(parameters: Record<string, unknown>): IWorkflowDb {
} as unknown as IWorkflowDb;
}
function workflowWithNodes(
...nodes: Array<{
id: string;
name: string;
type: string;
typeVersion: number;
position: number[];
parameters: Record<string, unknown>;
}>
): IWorkflowDb {
return {
id: 'wf-1',
name: 'Tool Workflow',
active: false,
createdAt: '',
updatedAt: '',
nodes,
connections: {},
settings: {},
versionId: 'v1',
} as unknown as IWorkflowDb;
}
describe('listWorkflowToolInputFields', () => {
it('returns declared workflowInputs fields', () => {
expect(
@@ -101,32 +77,6 @@ describe('listWorkflowToolInputFields', () => {
{ name: 'qty', type: 'number' },
]);
});
it('returns no fields when a supported trigger precedes the Execute Workflow Trigger', () => {
const chatTrigger = {
id: 'c1',
name: 'Chat Trigger',
type: CHAT_TRIGGER_NODE_TYPE,
typeVersion: 1,
position: [0, 0],
parameters: {},
};
const executeWorkflowTrigger = {
id: 'e1',
name: 'When Executed by Another Workflow',
type: EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
typeVersion: 1.1,
position: [200, 0],
parameters: {
inputSource: 'workflowInputs',
workflowInputs: { values: [{ name: 'chatId', type: 'string' }] },
},
};
const workflow = workflowWithNodes(chatTrigger, executeWorkflowTrigger);
expect(detectWorkflowToolTrigger(workflow)?.type).toBe(CHAT_TRIGGER_NODE_TYPE);
expect(listWorkflowToolInputFields(workflow)).toEqual([]);
});
});
describe('parseWorkflowToolFixedValue', () => {
@@ -0,0 +1,9 @@
import type { AgentConfigValidationIssue } from '@n8n/api-types';
/**
* A warning blocks publishing but not the draft preview: the workflow tool is
* compatible, its workflow just has no published version yet.
*/
export function isWarningIssue(issue: AgentConfigValidationIssue): boolean {
return issue.code === 'incompatible_reference' && issue.reason === 'not_published';
}
@@ -0,0 +1,13 @@
import type { SUPPORTED_WORKFLOW_TOOL_TRIGGERS } from '@n8n/api-types';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE } from 'n8n-workflow';
/** Locale key of each supported trigger's display name, keyed by node type so a rename is a one-key change. */
const TRIGGER_LABEL_KEYS: Record<(typeof SUPPORTED_WORKFLOW_TOOL_TRIGGERS)[number], BaseTextKey> = {
[EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]: 'nodeCreator.aiPanel.workflowTriggerDisplayName',
};
/** Display name of the trigger a workflow tool has to start with, for the `{trigger}` placeholder. */
export function workflowToolTriggerLabel(): string {
return useI18n().baseText(TRIGGER_LABEL_KEYS[EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE]);
}
@@ -143,6 +143,16 @@ function handleConnect() {
<N8nIcon icon="workflow" :size="20" />
</span>
<N8nText :class="$style.workflowTitle" tag="span" bold>{{ item.title }}</N8nText>
<N8nText
v-if="item.warning"
:class="$style.workflowWarning"
tag="span"
size="small"
color="warning"
data-test-id="tools-connection-row-warning"
>
{{ item.warning }}
</N8nText>
</template>
<template v-else>
@@ -362,6 +372,10 @@ function handleConnect() {
font-weight: var(--font-weight--medium);
}
.workflowWarning {
flex-shrink: 0;
}
.titleRow {
display: flex;
align-items: center;
@@ -241,6 +241,11 @@ describe('ToolRow', () => {
expect(queryByTestId('tools-connection-row-free-credits')).toBeNull();
});
it('shows the warning of a workflow row', () => {
const { getByTestId } = render({ ...baseWorkflow, warning: 'Not published' });
expect(getByTestId('tools-connection-row-warning').textContent).toContain('Not published');
});
it('keeps the verified badge on an installed community node', () => {
const item: NodeConnectionItem = { ...baseNode, verified: true };
const { getByTestId, queryByTestId } = render(item);
@@ -67,6 +67,8 @@ export interface NodeConnectionItem extends BaseConnectionItem {
export interface WorkflowConnectionItem extends BaseConnectionItem {
kind: 'workflow';
workflowId: string;
/** Short caveat shown next to the title, e.g. the workflow is not published. */
warning?: string;
}
export interface McpServerTool {
@@ -3,7 +3,7 @@ import { nanoid } from 'nanoid';
import { expect, test } from '../../../fixtures/base';
const CHILD_TRIGGER_NAME = 'Manual Trigger';
const CHILD_TRIGGER_NAME = 'When Executed by Another Workflow';
const CHILD_INPUT_NODE_NAME = 'Prepare workflow tool input';
const CHILD_OUTPUT_NODE_NAME = 'Prepare workflow tool output';
const NODE_TOOL_NAME = 'Node tool';
@@ -33,10 +33,10 @@ function childWorkflow(): Partial<IWorkflowBase> {
{
id: nanoid(),
name: CHILD_TRIGGER_NAME,
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
type: 'n8n-nodes-base.executeWorkflowTrigger',
typeVersion: 1.1,
position: [0, 0],
parameters: {},
parameters: { inputSource: 'passthrough' },
},
{
id: nanoid(),