feat(editor): Show the policy reason when an agent tool call is refused (#40673)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Guillaume Jacquart 2026-10-13 12:19:29 +00:00 committed by GitHub
parent 3075b60576
commit bb45bca5ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 800 additions and 23 deletions

View File

@ -1,12 +1,25 @@
import type { AgentJsonToolConfig } from '@n8n/api-types';
import type { PolicyViolation } from '@n8n/decorators';
import { Container } from '@n8n/di';
import { hasPolicyRefusalMarker } from 'n8n-workflow';
import { z } from 'zod';
import type { EphemeralNodeExecutor } from '@/node-execution';
import { NodeTypes } from '@/node-types';
import type { NonEmptyViolations } from '@/policy/policy-violation.error';
import { resolveNodeTool } from '../node-tool-factory';
const violation = (overrides: Partial<PolicyViolation> = {}): PolicyViolation => ({
kind: 'node-type-unavailable',
checkId: 'node-type-availability',
message: 'The node type n8n-nodes-base.googleDrive is not available on this instance',
subject: 'n8n-nodes-base.googleDrive',
subjectType: 'nodeType',
scope: 'instance',
...overrides,
});
// The node-tool-factory imports the DI `Container` to look up NodeTypes inside
// `resolveInputSchema` (for auto-seeding a `{ input: string }` schema on
// native tools). In the unit-test scope the container isn't registered and
@ -212,6 +225,48 @@ describe('resolveNodeTool → tool name sanitization', () => {
});
});
describe('resolveNodeTool → policy refusal', () => {
it('returns a policy refusal in the serialized refusal shape instead of throwing', async () => {
const violations: NonEmptyViolations = [violation()];
const executeInline = vi.fn().mockResolvedValue({
status: 'error',
data: [],
error: 'Node type is not permitted for agent tool execution',
violations,
});
const tool = await resolveNodeTool(baseToolSchema, {
executor: { executeInline } as unknown as EphemeralNodeExecutor,
projectId: 'p1',
});
const output = await tool.handler!({}, {} as never);
expect(output).toEqual({
isPolicyRefusal: true,
message: 'Node type is not permitted for agent tool execution',
violations,
instruction: expect.stringMatching(/do not retry/i),
});
expect(output).toMatchObject({ instruction: expect.stringMatching(/tell the user/i) });
expect(hasPolicyRefusalMarker(output)).toBe(true);
});
it('throws for an ordinary executor error with no violations, as before', async () => {
const executeInline = vi.fn().mockResolvedValue({
status: 'error',
data: [],
error: 'boom',
});
const tool = await resolveNodeTool(baseToolSchema, {
executor: { executeInline } as unknown as EphemeralNodeExecutor,
projectId: 'p1',
});
await expect(tool.handler!({}, {} as never)).rejects.toThrow('boom');
});
});
describe('toExecutorCredentials (credential mapping at execution)', () => {
type NodeCredentials = Extract<AgentJsonToolConfig, { type: 'node' }>['node']['credentials'];

View File

@ -17,6 +17,7 @@ import { resolveToolNodeType, type EphemeralNodeExecutor } from '@/node-executio
import { NodeTypes } from '@/node-types';
import type { InstrumentToolAdditionalData } from '../agent-runtime-instrumentation';
import { toPolicyRefusalResult } from './policy-refusal';
type NodeToolInputSchema = JSONSchema7 | z.ZodType;
@ -164,6 +165,13 @@ export async function resolveNodeTool(
projectId: ctx.projectId,
...instrumentedExecution,
});
// A refusal is returned, not thrown: thrown errors reach the chat as plain text.
if (result.status === 'error' && result.violations) {
return toPolicyRefusalResult(
result.error ?? result.violations[0].message,
result.violations,
);
}
// Throw on the executor's structured error so the agent runtime
// flags the tool-result with `isError: true` and the recorder
// marks the timeline entry as a failed call. Returning the error

View File

@ -0,0 +1,20 @@
import type { NonEmptyViolations } from '@/policy/policy-violation.error';
const POLICY_REFUSAL_INSTRUCTION =
'An administrator policy refused this tool call. Do not retry it or work around it. ' +
'Tell the user which action you could not do and why, then continue with the rest of the request.';
/** Same fields as a serialized `PolicyViolationError`, so existing refusal readers apply. */
export interface ToolPolicyRefusalResult {
isPolicyRefusal: true;
message: string;
violations: NonEmptyViolations;
instruction: string;
}
export function toPolicyRefusalResult(
message: string,
violations: NonEmptyViolations,
): ToolPolicyRefusalResult {
return { isPolicyRefusal: true, message, violations, instruction: POLICY_REFUSAL_INSTRUCTION };
}

View File

@ -498,6 +498,7 @@ describe('EphemeralNodeExecutor', () => {
expect(result.status).toBe('error');
expect(result.error).toBe('upstream 500');
expect(result).not.toHaveProperty('violations');
},
);
@ -662,6 +663,7 @@ describe('EphemeralNodeExecutor', () => {
expect(result.status).toBe('error');
expect(result.error).toBe('upstream 500');
expect(result).not.toHaveProperty('violations');
},
);
@ -771,13 +773,14 @@ describe('EphemeralNodeExecutor', () => {
);
});
it('returns a tool error and never runs a blocked node', async () => {
it("returns a tool error carrying the refusal's violations, and never runs a blocked node", async () => {
const execute = vi.fn();
nodeTypes.getByNameAndVersion.mockReturnValue({
description: toolDescription,
execute,
} as unknown as INodeType);
policyEnforcementService.enforceWorkflowStart.mockRejectedValueOnce(blocked());
const refusal = blocked();
policyEnforcementService.enforceWorkflowStart.mockRejectedValueOnce(refusal);
const result = await executor.executeInline({
nodeType: 'n8n-nodes-base.dateTimeTool',
@ -791,16 +794,18 @@ describe('EphemeralNodeExecutor', () => {
status: 'error',
data: [],
error: expect.stringContaining('is blocked by an instance policy'),
violations: refusal.violations,
});
expect(execute).not.toHaveBeenCalled();
});
it('never runs supplyData for a blocked native tool node', async () => {
it("never runs supplyData for a blocked native tool node, and carries the refusal's violations", async () => {
const supplyData = vi.fn();
nodeTypes.getByNameAndVersion.mockReturnValue(
mockNodeType({ description: toolDescription, supplyData }),
);
policyEnforcementService.enforceWorkflowStart.mockRejectedValueOnce(blocked());
const refusal = blocked();
policyEnforcementService.enforceWorkflowStart.mockRejectedValueOnce(refusal);
const result = await executor.executeInline({
nodeType: '@n8n/n8n-nodes-langchain.toolWikipedia',
@ -811,6 +816,7 @@ describe('EphemeralNodeExecutor', () => {
});
expect(result.status).toBe('error');
expect(result.violations).toEqual(refusal.violations);
expect(supplyData).not.toHaveBeenCalled();
});

View File

@ -28,6 +28,7 @@ import { v4 as uuid } from 'uuid';
import { NodeTypes } from '@/node-types';
import { PolicyEnforcementService } from '@/policy/policy-enforcement.service';
import { findPolicyViolations, type NonEmptyViolations } from '@/policy/policy-violation.error';
import { withExpressionIsolate } from '@/utils';
import { getBase } from '@/workflow-execute-additional-data';
@ -71,6 +72,23 @@ export interface NodeExecutionResult {
status: 'success' | 'error';
data: INodeExecutionData[];
error?: string;
/** Set when a policy refused the run. */
violations?: NonEmptyViolations;
}
type SupplyDataToolOutcome<T> =
| { ok: true; value: T }
| { ok: false; error: string; violations?: NonEmptyViolations };
/** The `{ status: 'error' }` result for a caught error, keeping a refusal's violations. */
function errorResult(error: unknown): NodeExecutionResult {
const violations = findPolicyViolations(error);
return {
status: 'error',
data: [],
error: getErrorMessage(error),
...(violations && { violations }),
};
}
/**
@ -392,9 +410,9 @@ export class EphemeralNodeExecutor {
);
return executionResult;
} catch (error) {
const message = getErrorMessage(error);
this.logger.debug('Node execution failed', { nodeType: tool.nodeType, error: message });
return { status: 'error', data: [], error: message };
const result = errorResult(error);
this.logger.debug('Node execution failed', { nodeType: tool.nodeType, error: result.error });
return result;
}
}
@ -514,7 +532,7 @@ export class EphemeralNodeExecutor {
tool: EphemeralWorkflowToolLike,
inputItems: INodeExecutionData[],
onTool: (response: LangChainToolType | StructuredToolkit) => Promise<T> | T,
): Promise<{ ok: true; value: T } | { ok: false; error: string }> {
): Promise<SupplyDataToolOutcome<T>> {
const closeFunctions: CloseFunction[] = [];
try {
@ -560,8 +578,8 @@ export class EphemeralNodeExecutor {
};
});
} catch (error) {
const message = getErrorMessage(error);
return { ok: false, error: message };
const violations = findPolicyViolations(error);
return { ok: false, error: getErrorMessage(error), ...(violations && { violations }) };
} finally {
for (const closeFunction of closeFunctions) {
try {
@ -611,7 +629,12 @@ export class EphemeralNodeExecutor {
nodeType: tool.nodeType,
error: result.error,
});
return { status: 'error', data: [], error: result.error };
return {
status: 'error',
data: [],
error: result.error,
...(result.violations && { violations: result.violations }),
};
}
return {

View File

@ -1,10 +1,13 @@
import type { PolicyViolation } from '@n8n/decorators';
import { UserError } from 'n8n-workflow';
import { NodeOperationError, UserError } from 'n8n-workflow';
import type { INode } from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import { classifyRestError, RestErrorKind } from '@n8n/backend-services';
import { serializeInternalRestError } from '@n8n/backend-services';
import {
findPolicyViolations,
isPolicyRefusal,
PolicyViolationError,
type NonEmptyViolations,
@ -122,4 +125,42 @@ describe('PolicyViolationError', () => {
expect(isPolicyRefusal(value)).toBe(false);
});
});
describe('findPolicyViolations', () => {
function wrapWithCauses(innermost: Error, wrapperCount: number): Error {
let current = innermost;
for (let i = 0; i < wrapperCount; i++) {
current = new Error(`wrapper ${i}`, { cause: current });
}
return current;
}
const violations: NonEmptyViolations = [violation()];
it('returns the violations of a direct refusal', () => {
expect(findPolicyViolations(new PolicyViolationError(violations))).toEqual(violations);
});
it('returns the violations of a refusal a node error wraps', () => {
const nodeError = new NodeOperationError(mock<INode>(), new PolicyViolationError(violations));
expect(findPolicyViolations(nodeError)).toEqual(violations);
});
it('walks at most five errors down the cause chain', () => {
const refusal = new PolicyViolationError(violations);
expect(findPolicyViolations(wrapWithCauses(refusal, 4))).toEqual(violations);
expect(findPolicyViolations(wrapWithCauses(refusal, 5))).toBeUndefined();
});
it.each([
['a plain error chain', wrapWithCauses(new Error('root cause'), 3)],
['a plain object', { violations }],
['a string', 'boom'],
['undefined', undefined],
])('returns undefined for %s', (_label, value) => {
expect(findPolicyViolations(value)).toBeUndefined();
});
});
});

View File

@ -63,3 +63,18 @@ export class PolicyViolationError extends UserError {
export function isPolicyRefusal(error: unknown): boolean {
return error instanceof PolicyViolationError || hasPolicyRefusalMarker(error);
}
/** Bounds the `cause` walk, so a cyclic chain cannot loop. */
const MAX_CAUSE_DEPTH = 5;
/** The violations of a refusal, also when a node error wraps it as its `cause`. */
export function findPolicyViolations(error: unknown): NonEmptyViolations | undefined {
let current = error;
for (let depth = 0; depth < MAX_CAUSE_DEPTH && current instanceof Error; depth++) {
if (current instanceof PolicyViolationError && hasViolations(current.violations)) {
return current.violations;
}
current = current.cause;
}
return undefined;
}

View File

@ -102,4 +102,52 @@ describe('N8nAiActivityStep', () => {
expect(container.querySelector('.n8n-icon')).not.toBeInTheDocument();
expect(queryByText('Custom details')).not.toBeInTheDocument();
});
it('should default the error icon to triangle-alert', () => {
const { container } = render(AiActivityStep, {
props: { label: 'Search nodes', error: 'Something went wrong' },
global,
});
expect(container.querySelector('.n8n-icon')?.getAttribute('icon')).toBe('triangle-alert');
});
it('should use a custom errorIcon when provided', () => {
const { container } = render(AiActivityStep, {
props: { label: 'Search nodes', error: 'Something went wrong', errorIcon: 'lock' },
global,
});
expect(container.querySelector('.n8n-icon')?.getAttribute('icon')).toBe('lock');
});
it('should render errorCallout slot content instead of the default error text', () => {
const { getByText, queryByText } = render(AiActivityStep, {
props: { label: 'Search nodes', error: 'Something went wrong' },
slots: { errorCallout: '<span>Custom callout text</span>' },
global,
});
expect(getByText('Custom callout text')).toBeInTheDocument();
// The tooltip is hidden by default in this test environment, so the only
// remaining place `error` could render is the callout body it replaced.
expect(queryByText('Something went wrong')).not.toBeInTheDocument();
});
it('should still show the error text in the tooltip when errorCallout slot is used', () => {
const { getByText } = render(AiActivityStep, {
props: { label: 'Search nodes', error: 'Something went wrong' },
slots: { errorCallout: '<span>Custom callout text</span>' },
global: {
...global,
stubs: {
...global.stubs,
N8nTooltip: { template: '<div><slot /><slot name="content" /></div>' },
},
},
});
expect(getByText('Something went wrong')).toBeInTheDocument();
expect(getByText('Custom callout text')).toBeInTheDocument();
});
});

View File

@ -10,6 +10,7 @@ import N8nAiActivityStepResultSection from '../N8nAiActivityStepResultSection';
import N8nAnimatedCollapsibleContent from '../N8nAnimatedCollapsibleContent';
import N8nCallout from '../N8nCallout';
import N8nIcon from '../N8nIcon';
import type { IconName } from '../N8nIcon/icons';
import N8nTooltip from '../N8nTooltip';
const props = withDefaults(
@ -23,6 +24,7 @@ const props = withDefaults(
wrapContent?: boolean;
/** Keep error icon/tooltip but skip the inner danger callout. */
hideErrorCallout?: boolean;
errorIcon?: IconName;
}>(),
{
loading: false,
@ -30,9 +32,16 @@ const props = withDefaults(
hasContent: true,
wrapContent: false,
hideErrorCallout: false,
errorIcon: 'triangle-alert',
},
);
defineSlots<{
default?: () => unknown;
/** Replaces the callout text; the tooltip keeps showing `error`. */
errorCallout?: () => unknown;
}>();
const MAX_ERROR_TOOLTIP_LENGTH = 160;
const isNested = inject(aiActivityStepGroupContext, false);
@ -57,7 +66,7 @@ const errorTooltip = computed(() =>
<span :class="$style.errorTooltip">{{ errorTooltip }}</span>
</template>
<N8nIcon
icon="triangle-alert"
:icon="props.errorIcon"
color="danger"
size="small"
:class="$style.activityErrorIcon"
@ -79,7 +88,7 @@ const errorTooltip = computed(() =>
theme="danger"
:class="$style.errorCallout"
>
{{ props.error }}
<slot name="errorCallout">{{ props.error }}</slot>
</N8nCallout>
</N8nAnimatedCollapsibleContent>
</CollapsibleRoot>
@ -91,7 +100,7 @@ const errorTooltip = computed(() =>
<span :class="$style.errorTooltip">{{ errorTooltip }}</span>
</template>
<N8nIcon
icon="triangle-alert"
:icon="props.errorIcon"
color="danger"
size="small"
:class="$style.activityErrorIcon"

View File

@ -8322,6 +8322,7 @@
"agents.chat.plan.status.canceled": "Canceled",
"agents.chat.streamInterrupted": "The connection was interrupted. Refresh the page to see the latest response.",
"agents.chat.toolError.generic": "Something went wrong while running this tool",
"agents.chat.toolPolicyRefusal.reason": "{name} is restricted on this instance.",
"agents.chat.clearHistory": "Clear chat history",
"agents.chat.input.placeholder": "Type a message...",
"agents.chat.input.placeholder.withAgent": "Message {agentName}…",

View File

@ -8,7 +8,7 @@ import { WRITE_TODOS_TOOL_NAME } from '../utils/write-todos-tool';
vi.mock('@n8n/design-system', () => ({
N8nAiActivityStep: {
props: ['label', 'hasContent', 'loading', 'error', 'hideErrorCallout'],
props: ['label', 'hasContent', 'loading', 'error', 'hideErrorCallout', 'errorIcon'],
data: () => ({ isOpen: false }),
computed: {
labelParts(this: { label: string }): string[] {
@ -25,8 +25,15 @@ vi.mock('@n8n/design-system', () => ({
<span> · </span>
<span data-testid="tool-step-summary">{{ part }}</span>
</template>
<span v-if="error" data-testid="tool-step-warning" :title="error">⚠</span>
<div v-if="error && !hideErrorCallout" data-test-id="tool-step-error">{{ error }}</div>
<span
v-if="error"
data-testid="tool-step-warning"
:title="error"
:data-error-icon="errorIcon"
>⚠</span>
<div v-if="error && !hideErrorCallout" data-test-id="tool-step-error">
<slot name="errorCallout">{{ error }}</slot>
</div>
<div v-if="isOpen"><slot /></div>
</div>
`,
@ -50,6 +57,11 @@ vi.mock('@n8n/design-system', () => ({
template: '<i :data-icon="icon" />',
props: ['icon', 'size', 'spin'],
},
N8nLink: {
props: ['underline', 'size'],
emits: ['click'],
template: '<a href="#" @click="$emit(\'click\', $event)"><slot /></a>',
},
N8nMarkdownEditor: {
template: '<div data-test-id="tool-step-details">{{ modelValue }}</div>',
props: ['modelValue', 'readonly', 'variant', 'showToolbar', 'maxHeight'],
@ -57,6 +69,33 @@ vi.mock('@n8n/design-system', () => ({
N8nTooltip: { template: '<div><slot /></div>', props: ['content', 'placement'] },
}));
vi.mock('@n8n/frontend-module-type-availability-policies', async () => ({
getPolicyViolations: (
await vi.importActual<typeof import('@n8n/frontend-module-type-availability-policies')>(
'@n8n/frontend-module-type-availability-policies',
)
).getPolicyViolations,
ContactInstanceAdminModal: {
props: ['open', 'nodeTypeName', 'kind'],
template:
'<div v-if="open" data-test-id="contact-instance-admin-modal">{{ nodeTypeName }}</div>',
},
}));
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({
getNodeType: () => undefined,
loadNodeTypesIfNotLoaded: async () => {},
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
getCredentialTypeByName: () => undefined,
fetchCredentialTypes: async () => {},
}),
}));
vi.mock('@n8n/i18n', () => {
const i18n = {
translations: {
@ -76,6 +115,9 @@ vi.mock('@n8n/i18n', () => {
'agents.chat.writeTodos.hint.difficulty': 'Difficulty',
'agents.chat.writeTodos.hint.subAgent': 'Sub-agent',
'agents.chat.writeTodos.hint.expectedOutput': 'Expected output',
'agents.chat.toolPolicyRefusal.reason': '{name} is restricted on this instance.',
'typeAvailabilityPolicies.restrictedNode.scope.instance': 'Restricted on this instance',
'typeAvailabilityPolicies.restrictedNode.contactAdmin': 'Contact instance admin',
} as Record<string, string>,
baseText(key: string, opts?: { interpolate?: { name?: string; count?: string } }) {
if (key === 'agents.chat.delegate.label' && opts?.interpolate?.name) {
@ -90,7 +132,11 @@ vi.mock('@n8n/i18n', () => {
return `${opts.interpolate.count} tasks`;
}
if (key === 'agents.chat.writeTodos.summary.done') return 'done';
return this.translations[key] ?? key;
const template = this.translations[key];
if (template && opts?.interpolate?.name) {
return template.replace('{name}', opts.interpolate.name);
}
return template ?? key;
},
};
@ -765,4 +811,99 @@ describe('AgentChatToolSteps', () => {
expect(wrapper.text()).toContain('Weighing the sources.');
expect(wrapper.text()).not.toContain('Reasoning');
});
describe('policy-refused tool calls', () => {
function refusedCall(overrides: Partial<ToolCall> = {}): ToolCall {
return {
tool: 'search_nodes',
toolCallId: 'tc-refused',
state: TOOL_CALL_STATE.ERROR,
output: {
isPolicyRefusal: true,
message: 'Blocked by policy',
violations: [
{
kind: 'node-type-unavailable',
checkId: 'check-1',
message: 'Not allowed',
subject: 'slack',
scope: 'instance',
},
],
instruction: 'Ask the user to pick another tool.',
},
...overrides,
};
}
it('shows a lock with the scope line as tooltip', () => {
const icon = mountSteps([refusedCall()]).get('[data-testid="tool-step-warning"]');
expect(icon.attributes('data-error-icon')).toBe('lock');
expect(icon.attributes('title')).toBe('Restricted on this instance');
});
it('keeps the default error icon for a non-refusal error', () => {
const wrapper = mountSteps([
{
tool: 'search_nodes',
toolCallId: 'tc-err',
state: TOOL_CALL_STATE.ERROR,
output: 'Boom',
},
]);
expect(
wrapper.get('[data-testid="tool-step-warning"]').attributes('data-error-icon'),
).toBeUndefined();
});
it('shows the refusal reason and a contact-admin link in the callout', () => {
const wrapper = mountSteps([refusedCall()]);
const callout = wrapper.get('[data-test-id="tool-step-error"]');
// Node types are not loaded in this test, so the step label names the subject.
expect(callout.text()).toContain('Search nodes is restricted on this instance.');
expect(
callout.find('[data-test-id="agent-chat-tool-policy-refusal-contact-admin"]').exists(),
).toBe(true);
});
it('hides the raw Output section even when expanded, unlike a normal error', async () => {
const refusedWrapper = mountSteps([refusedCall()]);
await refusedWrapper.get('button').trigger('click');
expect(refusedWrapper.find('pre').exists()).toBe(false);
const normalErrorWrapper = mountSteps([
{
tool: 'search_nodes',
toolCallId: 'tc-err',
state: TOOL_CALL_STATE.ERROR,
output: { foo: 'bar' },
},
]);
await normalErrorWrapper.get('button').trigger('click');
expect(normalErrorWrapper.find('pre').exists()).toBe(true);
});
it('excludes a refusal from fixable failures while a normal error stays fixable', async () => {
const normalError: ToolCall = {
tool: 'search_nodes',
toolCallId: 'tc-normal',
state: TOOL_CALL_STATE.ERROR,
output: 'Connection failed',
};
const wrapper = mountSteps([normalError, refusedCall()], {
canFixWithAssistant: true,
executionId: 'exec-1',
});
const fixCallout = wrapper.get('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]');
expect(fixCallout.text()).toContain('Connection failed');
expect(fixCallout.text()).not.toContain('is restricted on this instance');
expect(wrapper.find('[data-test-id="agent-chat-tool-policy-refusal"]').exists()).toBe(true);
await wrapper.get('[data-test-id="agent-chat-tool-fix-with-assistant"]').trigger('click');
expect(wrapper.emitted('fixWithAssistant')).toEqual([
[[expect.objectContaining({ toolCallId: 'tc-normal' })]],
]);
});
});
});

View File

@ -2292,6 +2292,42 @@ describe('useAgentChatStream — SDK-aligned event handling', () => {
expect(hook.messages.value[1].toolCalls?.[0].state).toBe('error');
});
it('renders a policy-refused tool result as an error step even though the call resolves', async () => {
const events: AgentSseEvent[] = [
{ type: 'start-step' },
{
type: 'tool-call',
toolCallId: 'tc-refused',
toolName: 'http_request',
input: { url: 'https://example.com' },
},
{ type: 'finish-step' },
{
type: 'tool-result',
toolCallId: 'tc-refused',
toolName: 'http_request',
output: {
isPolicyRefusal: true,
message: 'Blocked by policy',
violations: [
{ kind: 'node-type-unavailable', checkId: 'check-1', message: 'Not allowed' },
],
instruction: 'Ask the user to pick another tool.',
},
isError: false,
},
{ type: 'done' },
];
globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch;
const hook = buildHook();
await hook.sendMessage('go');
await flushPromises();
await nextTick();
expect(hook.messages.value[1].toolCalls?.[0].state).toBe('error');
});
it('renders a completed delegate_subagent result as a done step', async () => {
const events: AgentSseEvent[] = [
{ type: 'start-step' },

View File

@ -0,0 +1,57 @@
<script setup lang="ts">
import { N8nLink } from '@n8n/design-system';
import {
ContactInstanceAdminModal,
type RestrictedTypeKind,
} from '@n8n/frontend-module-type-availability-policies';
import { useI18n } from '@n8n/i18n';
import { computed, onMounted, ref } from 'vue';
import type { ToolPolicyRefusal } from '@/features/ai/shared/agentsChat/toolPolicyRefusal';
import { useToolPolicyRefusalText } from '../composables/useToolPolicyRefusalText';
const props = defineProps<{
refusal: ToolPolicyRefusal;
/** Names the subject when a violation does not. */
fallbackName: string;
}>();
const i18n = useI18n();
const { subjectName, reason, loadSubjectTypes } = useToolPolicyRefusalText();
const isContactAdminOpen = ref(false);
const reasonText = computed(() =>
[...new Set(props.refusal.violations.map((v) => reason(v, props.fallbackName)))].join(' '),
);
const firstViolation = computed(() => props.refusal.violations[0]);
const adminSubject = computed(() => subjectName(firstViolation.value) ?? props.fallbackName);
const adminSubjectKind = computed<RestrictedTypeKind>(() =>
firstViolation.value.subjectType === 'credentialType' ? 'credential' : 'node',
);
onMounted(() => {
void loadSubjectTypes(props.refusal).catch(() => undefined);
});
</script>
<template>
<span data-test-id="agent-chat-tool-policy-refusal">
{{ reasonText }}
<N8nLink
underline
size="small"
data-test-id="agent-chat-tool-policy-refusal-contact-admin"
@click.prevent="isContactAdminOpen = true"
>
{{ i18n.baseText('typeAvailabilityPolicies.restrictedNode.contactAdmin') }}
</N8nLink>
<ContactInstanceAdminModal
v-model:open="isContactAdminOpen"
:node-type-name="adminSubject"
:kind="adminSubjectKind"
/>
</span>
</template>

View File

@ -29,6 +29,8 @@ import {
writeTodosSummaryLabel,
} from '../utils/write-todos-tool';
import { TOOL_CALL_STATE } from '../constants';
import { parseToolPolicyRefusal } from '@/features/ai/shared/agentsChat/toolPolicyRefusal';
import AgentChatToolPolicyRefusal from './AgentChatToolPolicyRefusal.vue';
const props = defineProps<{
toolCalls: ToolCall[];
@ -57,6 +59,8 @@ const fixableFailures = computed<AgentFixWithAssistantFailure[]>(() => {
if (dismissed.has(toolCall.toolCallId)) continue;
if (toolCall.state !== TOOL_CALL_STATE.ERROR) continue;
if (isRecoverablePlanError(toolCall)) continue;
// The assistant cannot lift a policy.
if (toolStepRefusal(toolCall)) continue;
const error = toolStepError(toolCall)?.trim();
if (!error) continue;
@ -174,9 +178,17 @@ function toolStepView(tc: ToolCall): ToolStepDisplay {
};
}
function toolStepRefusal(tc: ToolCall) {
if (tc.state !== TOOL_CALL_STATE.ERROR) return undefined;
return parseToolPolicyRefusal(tc.output);
}
function toolStepError(tc: ToolCall): string | undefined {
if (isRecoverablePlanError(tc)) return i18n.baseText('agents.chat.plan.error.rejected');
if (tc.state !== TOOL_CALL_STATE.ERROR) return undefined;
if (toolStepRefusal(tc)) {
return i18n.baseText('typeAvailabilityPolicies.restrictedNode.scope.instance');
}
if (isEmptyToolErrorPayload(tc.output)) {
return i18n.baseText('agents.chat.toolError.generic');
}
@ -184,9 +196,15 @@ function toolStepError(tc: ToolCall): string | undefined {
}
function hideToolErrorCallout(tc: ToolCall): boolean {
if (toolStepRefusal(tc)) return false;
return isRecoverablePlanError(tc) || (showFix.value && tc.state === TOOL_CALL_STATE.ERROR);
}
/** The refusal payload is for the model; the callout explains it to the user. */
function showToolOutput(tc: ToolCall): boolean {
return tc.output !== undefined && !toolStepRefusal(tc);
}
function emitFixWithAssistant() {
if (fixableFailures.value.length === 0) return;
emit('fixWithAssistant', fixableFailures.value);
@ -225,9 +243,16 @@ function hasActiveToolCall(): boolean {
:label="view.label"
:loading="isToolStepLoading(tc)"
:error="toolStepError(tc)"
:error-icon="toolStepRefusal(tc) ? 'lock' : undefined"
:hide-error-callout="hideToolErrorCallout(tc)"
:has-content="view.expandable"
>
<template v-if="toolStepRefusal(tc)" #errorCallout>
<AgentChatToolPolicyRefusal
:refusal="toolStepRefusal(tc)!"
:fallback-name="toolStepLabel(tc)"
/>
</template>
<div
v-if="tc.childProgress"
:class="$style.childProgress"
@ -270,7 +295,7 @@ function hasActiveToolCall(): boolean {
</span>
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
</div>
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
<div v-if="showToolOutput(tc)" :class="$style.toolDataSection">
<span :class="$style.toolDataLabel">
{{ i18n.baseText('agentSessions.timeline.output') }}
</span>
@ -288,9 +313,16 @@ function hasActiveToolCall(): boolean {
:label="toolStepView(tc).label"
:loading="isToolStepLoading(tc)"
:error="toolStepError(tc)"
:error-icon="toolStepRefusal(tc) ? 'lock' : undefined"
:hide-error-callout="hideToolErrorCallout(tc)"
:has-content="toolStepView(tc).expandable"
>
<template v-if="toolStepRefusal(tc)" #errorCallout>
<AgentChatToolPolicyRefusal
:refusal="toolStepRefusal(tc)!"
:fallback-name="toolStepLabel(tc)"
/>
</template>
<template v-for="view in [toolStepView(tc)]" :key="view.label">
<div
v-if="tc.childProgress"
@ -334,7 +366,7 @@ function hasActiveToolCall(): boolean {
</span>
<pre :class="$style.toolDataContent">{{ formatToolData(tc.input) }}</pre>
</div>
<div v-if="tc.output !== undefined" :class="$style.toolDataSection">
<div v-if="showToolOutput(tc)" :class="$style.toolDataSection">
<span :class="$style.toolDataLabel">
{{ i18n.baseText('agentSessions.timeline.output') }}
</span>

View File

@ -0,0 +1,122 @@
import type { PolicyViolation } from '@n8n/api-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ToolPolicyRefusal } from '@/features/ai/shared/agentsChat/toolPolicyRefusal';
import { useToolPolicyRefusalText } from '../useToolPolicyRefusalText';
const getNodeTypeMock = vi.fn();
const getCredentialTypeByNameMock = vi.fn();
const loadNodeTypesMock = vi.fn();
const fetchCredentialTypesMock = vi.fn();
vi.mock('@/app/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({
getNodeType: getNodeTypeMock,
loadNodeTypesIfNotLoaded: loadNodeTypesMock,
}),
}));
vi.mock('@/features/credentials/credentials.store', () => ({
useCredentialsStore: () => ({
getCredentialTypeByName: getCredentialTypeByNameMock,
fetchCredentialTypes: fetchCredentialTypesMock,
}),
}));
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, opts?: { interpolate?: { name?: string } }) =>
`${key}:${opts?.interpolate?.name}`,
}),
}));
function violation(overrides: Partial<PolicyViolation> = {}): PolicyViolation {
return {
kind: 'node-type-unavailable',
checkId: 'check-1',
message: 'Not allowed',
...overrides,
};
}
function refusal(violations: [PolicyViolation, ...PolicyViolation[]]): ToolPolicyRefusal {
return { violations };
}
describe('useToolPolicyRefusalText', () => {
beforeEach(() => {
vi.resetAllMocks();
});
describe('subjectName', () => {
it('names a generated `*Tool` variant by its usable-as-tool base type', () => {
getNodeTypeMock.mockImplementation((type: string) =>
type === 'n8n-nodes-base.gmail' ? { displayName: 'Gmail', usableAsTool: true } : undefined,
);
const { subjectName } = useToolPolicyRefusalText();
expect(subjectName(violation({ subject: 'n8n-nodes-base.gmailTool' }))).toBe('Gmail');
});
it('keeps the own name of a real node whose type ends in `Tool`', () => {
const nodeTypes: Record<string, object> = {
'@n8n/n8n-nodes-langchain.agent': { displayName: 'AI Agent' },
'@n8n/n8n-nodes-langchain.agentTool': { displayName: 'AI Agent Tool' },
};
getNodeTypeMock.mockImplementation((type: string) => nodeTypes[type]);
const { subjectName } = useToolPolicyRefusalText();
expect(subjectName(violation({ subject: '@n8n/n8n-nodes-langchain.agentTool' }))).toBe(
'AI Agent Tool',
);
});
it('names a credential subject from the credentials store', () => {
getCredentialTypeByNameMock.mockReturnValue({ displayName: 'Slack API' });
const { subjectName } = useToolPolicyRefusalText();
expect(subjectName(violation({ subject: 'slackApi', subjectType: 'credentialType' }))).toBe(
'Slack API',
);
});
it('returns undefined rather than the type id when the type is not loaded', () => {
const { subjectName } = useToolPolicyRefusalText();
expect(subjectName(violation({ subject: 'n8n-nodes-base.gmailTool' }))).toBeUndefined();
expect(subjectName(violation())).toBeUndefined();
});
});
describe('reason', () => {
it('names the resolved subject', () => {
getNodeTypeMock.mockReturnValue({ displayName: 'Gmail' });
const { reason } = useToolPolicyRefusalText();
expect(reason(violation({ subject: 'n8n-nodes-base.gmail' }), 'Send email')).toBe(
'agents.chat.toolPolicyRefusal.reason:Gmail',
);
});
it('names the tool when the subject is not resolvable', () => {
const { reason } = useToolPolicyRefusalText();
expect(reason(violation({ subject: 'n8n-nodes-base.gmailTool' }), 'Send email')).toBe(
'agents.chat.toolPolicyRefusal.reason:Send email',
);
});
});
describe('loadSubjectTypes', () => {
it('loads only the type lists the violations name', async () => {
const { loadSubjectTypes } = useToolPolicyRefusalText();
await loadSubjectTypes(refusal([violation({ subjectType: 'nodeType' })]));
expect(loadNodeTypesMock).toHaveBeenCalledTimes(1);
expect(fetchCredentialTypesMock).not.toHaveBeenCalled();
await loadSubjectTypes(refusal([violation({ subjectType: 'credentialType' })]));
expect(fetchCredentialTypesMock).toHaveBeenCalledWith(false);
expect(loadNodeTypesMock).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -48,6 +48,7 @@ import { getMessageThinkingSegments } from '@/features/ai/shared/agentsChat/thin
import type { ChatMessage, ThinkingSegment, ToolCall } from '@/features/ai/shared/agentsChat/types';
import { CHAT_MESSAGE_STATUS, TOOL_CALL_STATE } from '../constants';
import { summariseToolCall } from '@/features/ai/shared/agentsChat/interactiveSummary';
import { isPolicyRefusedToolOutput } from '@/features/ai/shared/agentsChat/toolPolicyRefusal';
import { isBudgetStopCode, type BudgetNoticeCode } from '../utils/budget-config';
import { isFailedDelegateOutput } from '../utils/delegate-tool';
import { useAgentExecutionUpdates } from './useAgentExecutionUpdates';
@ -1132,7 +1133,10 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
if (found) {
const toolResultEvent = event as typeof event & { canceled?: boolean };
found.tc.output = event.output;
const failed = event.isError || isFailedDelegateOutput(found.tc.tool, event.output);
const failed =
event.isError ||
isFailedDelegateOutput(found.tc.tool, event.output) ||
isPolicyRefusedToolOutput(event.output);
found.tc.state = failed
? TOOL_CALL_STATE.ERROR
: toolResultEvent.canceled === true

View File

@ -0,0 +1,50 @@
import type { PolicyViolation } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import type { ToolPolicyRefusal } from '@/features/ai/shared/agentsChat/toolPolicyRefusal';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
const TOOL_SUFFIX = 'Tool';
export function useToolPolicyRefusalText() {
const i18n = useI18n();
const nodeTypesStore = useNodeTypesStore();
const credentialsStore = useCredentialsStore();
function nodeTypeName(type: string): string | undefined {
// Agents run the generated `*Tool` variant of a usable-as-tool node. Real nodes such as
// `agentTool` also end in `Tool`, so only a usable-as-tool base names the subject.
if (type.endsWith(TOOL_SUFFIX)) {
const base = nodeTypesStore.getNodeType(type.slice(0, -TOOL_SUFFIX.length));
if (base?.usableAsTool) return base.displayName;
}
return nodeTypesStore.getNodeType(type)?.displayName;
}
/** Undefined until the types are loaded, so callers show a readable fallback, not a type id. */
function subjectName(violation: PolicyViolation): string | undefined {
const { subject, subjectType } = violation;
if (!subject) return undefined;
return subjectType === 'credentialType'
? credentialsStore.getCredentialTypeByName(subject)?.displayName
: nodeTypeName(subject);
}
/** The agent builder does not load node or credential types up front. */
async function loadSubjectTypes(refusal: ToolPolicyRefusal) {
const types = new Set(refusal.violations.map((v) => v.subjectType));
await Promise.all([
types.has('credentialType') ? credentialsStore.fetchCredentialTypes(false) : undefined,
types.has('nodeType') ? nodeTypesStore.loadNodeTypesIfNotLoaded() : undefined,
]);
}
function reason(violation: PolicyViolation, fallbackName: string): string {
return i18n.baseText('agents.chat.toolPolicyRefusal.reason', {
interpolate: { name: subjectName(violation) ?? fallbackName },
});
}
return { subjectName, reason, loadSubjectTypes };
}

View File

@ -0,0 +1,60 @@
import type { AgentPersistedMessageDto } from '@n8n/api-types';
import { TOOL_CALL_STATE } from '../constants';
import { convertDbMessages } from '../messageMappers';
const refusalOutput = {
isPolicyRefusal: true,
message: 'Blocked by policy',
violations: [{ kind: 'node-type-unavailable', checkId: 'check-1', message: 'Not allowed' }],
instruction: 'Ask the user to pick another tool.',
};
describe('convertDbMessages — policy-refused tool output', () => {
it('marks a resolved tool call as errored when its output is a policy refusal', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'http_request',
toolCallId: 'tc-refused',
input: { url: 'https://example.com' },
output: refusalOutput,
state: 'resolved',
},
],
},
];
const chat = convertDbMessages(dbMessages);
expect(chat[0].toolCalls?.[0].state).toBe(TOOL_CALL_STATE.ERROR);
expect(chat[0].toolCalls?.[0].output).toEqual(refusalOutput);
});
it('leaves a resolved tool call done when its output is not a policy refusal', () => {
const dbMessages: AgentPersistedMessageDto[] = [
{
id: 'm2',
role: 'assistant',
content: [
{
type: 'tool-call',
toolName: 'http_request',
toolCallId: 'tc-ok',
input: { url: 'https://example.com' },
output: { status: 200 },
state: 'resolved',
},
],
},
];
const chat = convertDbMessages(dbMessages);
expect(chat[0].toolCalls?.[0].state).toBe(TOOL_CALL_STATE.DONE);
});
});

View File

@ -0,0 +1,27 @@
import { isPolicyRefusedToolOutput, parseToolPolicyRefusal } from '../toolPolicyRefusal';
const violation = { kind: 'node-type-unavailable', checkId: 'check-1', message: 'Not allowed' };
const refusal = {
isPolicyRefusal: true,
message: 'Blocked by policy',
violations: [violation],
instruction: 'Ask the user to pick another tool.',
};
describe('parseToolPolicyRefusal', () => {
it('reads the violations of a serialized policy refusal', () => {
expect(parseToolPolicyRefusal(refusal)).toEqual({ violations: [violation] });
expect(isPolicyRefusedToolOutput(refusal)).toBe(true);
});
it.each([
['undefined', undefined],
['a plain string', 'Something failed'],
['an ordinary tool output', { foo: 'bar' }],
['violations without the refusal marker', { violations: [violation] }],
['a refusal with no violations', { ...refusal, violations: [] }],
])('rejects %s', (_label, output) => {
expect(parseToolPolicyRefusal(output)).toBeUndefined();
expect(isPolicyRefusedToolOutput(output)).toBe(false);
});
});

View File

@ -18,6 +18,7 @@ import type { ToolCallState } from './constants';
import { isDelegateSubAgentTool, isFailedDelegateOutput } from './delegateTool';
import type { AgentsChatInteractionExtension } from './interactionRegistry';
import { summariseToolCall } from './interactiveSummary';
import { isPolicyRefusedToolOutput } from './toolPolicyRefusal';
import type {
ApprovalInput,
ChatMessage,
@ -293,7 +294,10 @@ export function convertDbMessages(
output = part.output;
if (canceled) {
state = TOOL_CALL_STATE.CANCELLED;
} else if (isFailedDelegateOutput(part.toolName, part.output)) {
} else if (
isFailedDelegateOutput(part.toolName, part.output) ||
isPolicyRefusedToolOutput(part.output)
) {
state = TOOL_CALL_STATE.ERROR;
} else {
state = TOOL_CALL_STATE.DONE;

View File

@ -0,0 +1,18 @@
import type { PolicyViolation } from '@n8n/api-types';
import { getPolicyViolations } from '@n8n/frontend-module-type-availability-policies';
import { hasPolicyRefusalMarker } from 'n8n-workflow';
export interface ToolPolicyRefusal {
violations: [PolicyViolation, ...PolicyViolation[]];
}
/** A tool output in the serialized policy-refusal shape, the one executions also store. */
export function parseToolPolicyRefusal(output: unknown): ToolPolicyRefusal | undefined {
if (!hasPolicyRefusalMarker(output)) return undefined;
const [first, ...rest] = getPolicyViolations(output) ?? [];
return first ? { violations: [first, ...rest] } : undefined;
}
export function isPolicyRefusedToolOutput(output: unknown): boolean {
return parseToolPolicyRefusal(output) !== undefined;
}