mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
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:
parent
3075b60576
commit
bb45bca5ff
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
20
packages/cli/src/modules/agents/tools/policy-refusal.ts
Normal file
20
packages/cli/src/modules/agents/tools/policy-refusal.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}…",
|
||||
|
|
|
|||
|
|
@ -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' })]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user