fix(ai-builder): Emit tool call events for Workflow, HTTP Request and Code tools (#37044)

This commit is contained in:
Anne Aguirre 2026-08-31 12:39:49 +00:00 committed by GitHub
parent 464fb4701a
commit 7e8161043d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 848 additions and 59 deletions

View File

@ -42,6 +42,106 @@ describe('logAiEvent', () => {
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledTimes(1);
});
it('should sanitize credential-shaped values on tool-called events', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: { api_key: 'sk-live-abcdef123456' },
response: 'authorization: Bearer eyJhbGciOiJIUzI1NiJ9',
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('api_key');
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
expect(payload).not.toContain('eyJhbGciOiJIUzI1NiJ9');
});
it('should sanitize credential-shaped values inside stringified JSON responses', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: 'test query',
response: JSON.stringify({ api_key: 'sk-live-abcdef123456' }, null, 2),
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
});
it('should replace object-valued credential properties instead of restoring them', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: { api_key: { nested: 'sk-live-abcdef123456' } },
response: { client_secret: ['cs-live-abcdef'] },
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
expect(payload).not.toContain('cs-live-abcdef');
});
it('should sanitize unlabeled credential formats in tool-called events', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: 'lookup user',
response: 'the run used sk-abc123DEF456ghi789jkl012',
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('sk-abc123DEF456ghi789jkl012');
});
it('should sanitize composite credential field names in tool-called events', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: { accessKeyId: 'AKIAIOSFODNN7EXAMPLE' },
response: { secretAccessKey: 'wJalrXUtnFEMI/K7MDENG', Cookie: 'session=abc123' },
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('AKIAIOSFODNN7EXAMPLE');
expect(payload).not.toContain('wJalrXUtnFEMI/K7MDENG');
expect(payload).not.toContain('session=abc123');
});
it('should preserve Date serialization in tool-called events', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: 'lookup user',
response: {
createdAt: new Date('2026-08-25T12:00:00.000Z'),
message: 'ok',
},
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('2026-08-25T12:00:00.000Z');
expect(payload).toContain('"message":"ok"');
});
it('should preserve custom toJSON serialization in tool-called events', () => {
logAiEvent(mockExecuteFunctions, 'ai-tool-called', {
query: 'lookup user',
response: {
toJSON() {
return { status: 'ok', issuedAt: new Date('2020-01-01T00:00:00.000Z') };
},
},
});
const payload = mockExecuteFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('"status":"ok"');
expect(payload).toContain('2020-01-01T00:00:00.000Z');
});
it('should leave other events unsanitized', () => {
const data: IDataObject = { response: 'api_key: sk-live-abcdef123456' };
logAiEvent(mockExecuteFunctions, 'ai-llm-generated-output', data);
expect(mockExecuteFunctions.logAiEvent).toHaveBeenCalledWith(
'ai-llm-generated-output',
JSON.stringify(data),
);
});
it('should log different AI event types', () => {
const events: AiEvent[] = ['ai-llm-generated-output', 'ai-llm-errored', 'ai-tool-called'];
const data: IDataObject = { test: 'data' };

View File

@ -0,0 +1,153 @@
import { redactSecrets, sanitizeCredentialShapedValues } from 'src/utils/redact-secrets';
const join = (prefix: string, suffix: string) => prefix + suffix;
describe('redactSecrets', () => {
it('should replace credential-shaped values and keep the key', () => {
expect(redactSecrets('Rejected request, api_key: sk-live-abcdef123456')).toBe(
'Rejected request, api_key: [REDACTED]',
);
expect(redactSecrets('authorization: Bearer eyJhbGciOiJIUzI1NiJ9')).toBe(
'authorization: [REDACTED]',
);
});
it('should replace compound keys and unprefixed authorization values', () => {
const input = JSON.stringify({
message: 'Token exchange failed',
client_secret: 'cs-live-abcdef',
Authorization: 'abcdef-bare-key',
token_type: 'Bearer',
});
const redacted = redactSecrets(input);
expect(redacted).toContain('Token exchange failed');
expect(redacted).toContain('client_secret');
expect(redacted).not.toContain('cs-live-abcdef');
expect(redacted).not.toContain('abcdef-bare-key');
expect(redacted).toContain('"token_type":"Bearer"');
});
it('should leave strings without credential-shaped values unchanged', () => {
expect(redactSecrets('Hello World')).toBe('Hello World');
});
it('should sanitize unlabeled credential formats', () => {
expect(redactSecrets('the run used sk-abc123DEF456ghi789jkl012 for calls')).toBe(
'the run used [REDACTED] for calls',
);
expect(redactSecrets('cloned using ghp_ABCdef123456789012345678901234567890')).toBe(
'cloned using [REDACTED]',
);
expect(redactSecrets('signed as AKIAIOSFODNN7EXAMPLE today')).toBe(
'signed as [REDACTED] today',
);
expect(redactSecrets('got Bearer eyJhbGciOiJIUzI1NiJ9 back')).toBe('got [REDACTED] back');
});
it('should leave unlabeled lookalikes unchanged', () => {
expect(redactSecrets('we use sk-learn for clustering')).toBe('we use sk-learn for clustering');
expect(redactSecrets('AKIA is an AWS prefix')).toBe('AKIA is an AWS prefix');
});
it('should sanitize JWT values', () => {
const jwt = `${join('eyJ', 'hbGciOiJIUzI1NiJ9')}.${join('eyJ', 'zdWIiOiIxMjMifQ')}.c2lnbmF0dXJl`;
expect(redactSecrets(`token ${jwt} end`)).toBe('token [REDACTED] end');
});
it('should sanitize PEM private-key blocks', () => {
const pem = `-----BEGIN PRIVATE KEY-----\n${'FAKEKEYMATERIAL'}\n-----END PRIVATE KEY-----`;
expect(redactSecrets(`key:\n${pem}\ndone`)).toBe('key:\n[REDACTED]\ndone');
});
it('should sanitize Token authorization values', () => {
expect(redactSecrets('header is Token abcdef1234567890')).toBe('header is [REDACTED]');
});
});
describe('sanitizeCredentialShapedValues', () => {
it('should replace object and array credential properties with a sentinel', () => {
expect(
sanitizeCredentialShapedValues({
api_key: { nested: 'sk-live-abcdef123456' },
client_secret: ['cs-live-abcdef'],
message: 'ok',
}),
).toEqual({
api_key: '[REDACTED]',
client_secret: '[REDACTED]',
message: 'ok',
});
});
it('should keep keys that only resemble credentials', () => {
expect(
sanitizeCredentialShapedValues({
token_type: 'Bearer',
access_token: 'xyz-live-token',
}),
).toEqual({
token_type: 'Bearer',
access_token: '[REDACTED]',
});
});
it('should sanitize composite credential field names', () => {
expect(
sanitizeCredentialShapedValues({
secretAccessKey: 'wJalrXUtnFEMI/K7MDENG',
accessKeyId: 'AKIAIOSFODNN7EXAMPLE',
Cookie: 'session=abc123',
secretKey: 'local-dev-key',
message: 'ok',
}),
).toEqual({
secretAccessKey: '[REDACTED]',
accessKeyId: '[REDACTED]',
Cookie: '[REDACTED]',
secretKey: '[REDACTED]',
message: 'ok',
});
});
it('should preserve Date serialization', () => {
const createdAt = new Date('2026-08-25T12:00:00.000Z');
expect(
sanitizeCredentialShapedValues({
createdAt,
message: 'ok',
}),
).toEqual({
createdAt: '2026-08-25T12:00:00.000Z',
message: 'ok',
});
});
it('should sanitize credential-shaped values inside stringified JSON', () => {
const result = sanitizeCredentialShapedValues({
response: JSON.stringify({ api_key: { nested: 'sk-live-abcdef123456' } }, null, 2),
});
expect(result).toEqual({
response: JSON.stringify({ api_key: '[REDACTED]' }, null, 2),
});
});
it('should keep pretty-printed JSON strings that do not need redaction', () => {
const pretty = JSON.stringify({ msg: 'test response' }, null, 2);
expect(sanitizeCredentialShapedValues({ response: pretty })).toEqual({
response: pretty,
});
});
it('should sanitize unlabeled credential formats in string fields', () => {
expect(
sanitizeCredentialShapedValues({
response: 'the run used sk-abc123DEF456ghi789jkl012',
}),
).toEqual({
response: 'the run used [REDACTED]',
});
});
});

View File

@ -4,6 +4,7 @@ export { AI_NODE_SDK_VERSION } from './ai-node-sdk-version';
// Utils
export { logWrapper } from './utils/log-wrapper';
export { logAiEvent } from './utils/log-ai-event';
export { redactSecrets } from './utils/redact-secrets';
export { parseSSEStream } from './utils/sse';
export {
validateEmbedQueryInput,

View File

@ -1,13 +1,17 @@
import type { AiEvent, IDataObject, IExecuteFunctions, ISupplyDataFunctions } from 'n8n-workflow';
import { jsonStringify } from 'n8n-workflow';
import { sanitizeCredentialShapedValues } from './redact-secrets';
export function logAiEvent(
executeFunctions: IExecuteFunctions | ISupplyDataFunctions,
event: AiEvent,
data?: IDataObject,
) {
try {
executeFunctions.logAiEvent(event, data ? jsonStringify(data) : undefined);
const payload =
event === 'ai-tool-called' && data ? sanitizeCredentialShapedValues(data) : data;
executeFunctions.logAiEvent(event, payload ? jsonStringify(payload) : undefined);
} catch (error) {
executeFunctions.logger.debug(`Error logging AI event: ${event}`);
}

View File

@ -0,0 +1,122 @@
import { redactDeep, redactText } from '@n8n/utils/redaction/redact-text';
import { jsonParse, jsonStringify } from 'n8n-workflow';
const OPTIONS = { secrets: true, redactSensitiveKeys: true };
function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
function parseJsonObject(value: string): object | undefined {
try {
const parsed: unknown = JSON.parse(value);
if (parsed !== null && typeof parsed === 'object') {
return parsed;
}
} catch {
return undefined;
}
return undefined;
}
/** Keep the original string when redaction is a no-op so pretty JSON stays pretty. */
function serializeJson(original: string, parsed: unknown, redacted: unknown): string {
if (jsonStringify(redacted) === jsonStringify(parsed)) {
return original;
}
return original.includes('\n') ? JSON.stringify(redacted, null, 2) : jsonStringify(redacted);
}
/** Expand JSON-looking strings so nested payloads can be walked as structure. */
function parseNestedJson(value: unknown): unknown {
if (typeof value === 'string') {
const parsed = parseJsonObject(value);
return parsed === undefined ? value : parseNestedJson(parsed);
}
if (Array.isArray(value)) {
return value.map(parseNestedJson);
}
if (isPlainObject(value)) {
const walked: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
walked[key] = parseNestedJson(child);
}
return walked;
}
return value;
}
/** Put parsed JSON strings back; keep the original when redaction did not change them. */
function restoreJsonStrings(original: unknown, redacted: unknown): unknown {
if (typeof original === 'string') {
const parsed = parseJsonObject(original);
if (parsed !== undefined) {
return serializeJson(original, parsed, restoreJsonStrings(parsed, redacted));
}
return redacted;
}
if (Array.isArray(original) && Array.isArray(redacted)) {
return original.map((item, index) => restoreJsonStrings(item, redacted[index]));
}
if (isPlainObject(original) && isPlainObject(redacted)) {
const restored: Record<string, unknown> = {};
for (const [key, child] of Object.entries(original)) {
restored[key] = restoreJsonStrings(child, redacted[key]);
}
return restored;
}
return redacted;
}
function redactJsonValue(value: unknown): unknown {
return restoreJsonStrings(value, redactDeep(parseNestedJson(value), OPTIONS).value);
}
/**
* Masks credential-shaped values in text. JSON objects are walked with
* `redactDeep` so keys stay visible; non-JSON text uses `redactText`.
*/
export const redactSecrets = (content: string): string => {
const parsed = parseJsonObject(content);
if (parsed !== undefined) {
return serializeJson(content, parsed, redactJsonValue(parsed));
}
const start = content.indexOf('{');
const end = content.lastIndexOf('}');
if (start !== -1 && end > start) {
const slice = content.slice(start, end + 1);
const nested = parseJsonObject(slice);
if (nested !== undefined) {
content =
content.slice(0, start) +
serializeJson(slice, nested, redactJsonValue(nested)) +
content.slice(end + 1);
}
}
return redactText(content, OPTIONS).text;
};
/**
* Replaces credential-named properties with a sentinel and masks credential-shaped
* text in strings. JSON.stringify runs first so Date and custom toJSON values
* keep their serialized form, then the JSON-compatible tree is walked.
*/
export function sanitizeCredentialShapedValues(value: unknown): unknown {
if (value !== null && typeof value === 'object') {
const parsed: unknown = jsonParse(jsonStringify(value), { fallbackValue: undefined });
if (parsed === undefined) {
throw new Error('Unable to sanitize tool-called payload');
}
return redactJsonValue(parsed);
}
return redactJsonValue(value);
}

View File

@ -5,6 +5,7 @@ import {
type INode,
type INodeExecutionData,
type ISupplyDataFunctions,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
@ -85,6 +86,95 @@ describe('ToolCode', () => {
expect(tool.description).toBe('description text');
expect(tool.func).toBeInstanceOf(Function);
});
it('should emit ai-tool-called on successful invocation', async () => {
const node = new ToolCode();
vi.mocked(JsTaskRunnerSandbox).mockImplementation(function (this: {
runCodeForTool: ReturnType<typeof vi.fn>;
}) {
this.runCodeForTool = vi.fn().mockResolvedValue('ok');
return this;
} as unknown as new (
...args: unknown[]
) => JsTaskRunnerSandbox);
const logAiEvent = vi.fn();
const ctx = mock<ISupplyDataFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
getNodeParameter: vi.fn().mockImplementation((paramName) => {
switch (paramName) {
case 'description':
return 'description text';
case 'specifyInputSchema':
return false;
case 'language':
return 'javaScript';
case 'jsCode':
return 'return "ok";';
default:
return;
}
}),
getMode: vi.fn((): WorkflowExecuteMode => 'manual'),
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
logAiEvent,
});
const supplyDataResult = await node.supplyData.call(ctx, 0);
const tool = supplyDataResult.response as DynamicTool;
await expect(tool.func('hello')).resolves.toBe('ok');
expect(logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({ query: 'hello', response: 'ok' }),
);
});
it('should sanitize credential-shaped values in the tool-called event', async () => {
const node = new ToolCode();
vi.mocked(JsTaskRunnerSandbox).mockImplementation(function (this: {
runCodeForTool: ReturnType<typeof vi.fn>;
}) {
this.runCodeForTool = vi.fn().mockResolvedValue('api_key: sk-live-abcdef123456');
return this;
} as unknown as new (
...args: unknown[]
) => JsTaskRunnerSandbox);
const logAiEvent = vi.fn();
const ctx = mock<ISupplyDataFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
getNodeParameter: vi.fn().mockImplementation((paramName) => {
switch (paramName) {
case 'description':
return 'description text';
case 'specifyInputSchema':
return false;
case 'language':
return 'javaScript';
case 'jsCode':
return 'return "ok";';
default:
return;
}
}),
getMode: vi.fn((): WorkflowExecuteMode => 'manual'),
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
logAiEvent,
});
const supplyDataResult = await node.supplyData.call(ctx, 0);
const tool = supplyDataResult.response as DynamicTool;
await expect(tool.func('query')).resolves.toBe('api_key: sk-live-abcdef123456');
const payload = logAiEvent.mock.calls[0][1];
expect(payload).toContain('api_key: [REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
});
});
describe('execute', () => {
@ -222,6 +312,7 @@ describe('ToolCode', () => {
}) as unknown as JsTaskRunnerSandbox,
);
const logAiEvent = vi.fn();
const mockExecute = mock<IExecuteFunctions>({
getInputData: vi.fn(() => inputData),
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
@ -241,16 +332,16 @@ describe('ToolCode', () => {
return;
}
}),
// @ts-expect-error - Mocking
getMode: vi.fn(() => 'manual'),
getMode: vi.fn((): WorkflowExecuteMode => 'manual'),
logAiEvent,
});
DynamicTool.prototype.invoke = vi.fn(async function (this: DynamicTool, args: unknown) {
return await this.func(args as string);
});
// @ts-expect-error - Mocking
await expect(node.execute.call(mockExecute)).rejects.toThrow(/boom/);
expect(logAiEvent).not.toHaveBeenCalled();
});
it('should keep returning error string when invoked via supplyData (legacy path)', async () => {
@ -263,33 +354,42 @@ describe('ToolCode', () => {
}) as unknown as JsTaskRunnerSandbox,
);
const supplyDataResult = await node.supplyData.call(
mock<ISupplyDataFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex) => {
switch (paramName) {
case 'description':
return 'description text';
case 'name':
return 'wrong_field';
case 'specifyInputSchema':
return false;
case 'language':
return 'javaScript';
case 'jsCode':
return 'throw new Error("boom");';
default:
return;
}
}),
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
const logAiEvent = vi.fn();
const ctx = mock<ISupplyDataFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.2, name: 'test tool' })),
getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex) => {
switch (paramName) {
case 'description':
return 'description text';
case 'name':
return 'wrong_field';
case 'specifyInputSchema':
return false;
case 'language':
return 'javaScript';
case 'jsCode':
return 'throw new Error("boom");';
default:
return;
}
}),
0,
);
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
logAiEvent,
});
const supplyDataResult = await node.supplyData.call(ctx, 0);
const tool = supplyDataResult.response as DynamicTool;
await expect(tool.func('query')).resolves.toMatch(/There was an error/);
expect(logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
expect.stringContaining('"query":"query"'),
);
expect(logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
expect.stringContaining('There was an error'),
);
});
});
});

View File

@ -26,7 +26,7 @@ import {
schemaTypeField,
} from '@utils/descriptions';
import { convertJsonSchemaToZod, generateSchemaFromExample } from '@utils/schemaParsing';
import { getConnectionHintNoticeField } from '@n8n/ai-utilities';
import { getConnectionHintNoticeField, logAiEvent } from '@n8n/ai-utilities';
import type { DynamicZodObject } from '../../../types/zod.types';
@ -121,6 +121,10 @@ function getTool(
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]);
}
if (log) {
logAiEvent(ctx, 'ai-tool-called', { query, response });
}
// When invoked from `execute` (log=false) the engine, not the agent, is
// driving execution: throw so workflow-execute can record the error
// against the tool run. Continue-on-fail for AI tools still lets the

View File

@ -53,6 +53,10 @@ describe('ToolHttpRequest', () => {
expect(helpers.httpRequest).toHaveBeenCalled();
expect(res).toContain('error');
expect(res).toContain('Binary data is not supported');
expect(executeFunctions.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
expect.stringContaining('Binary data is not supported'),
);
});
it('should return the response text when receiving a text response', async () => {
@ -83,6 +87,42 @@ describe('ToolHttpRequest', () => {
const res = await (response as N8nTool).invoke({});
expect(helpers.httpRequest).toHaveBeenCalled();
expect(res).toEqual('Hello World');
expect(executeFunctions.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({ query: '{}', response: 'Hello World' }),
);
});
it('should sanitize credential-shaped values in the tool-called event', async () => {
helpers.httpRequest.mockResolvedValue({
body: 'api_key: sk-live-abcdef123456',
headers: {
'content-type': 'text/plain',
},
});
executeFunctions.getNodeParameter.mockImplementation((paramName: string) => {
switch (paramName) {
case 'method':
return 'GET';
case 'url':
return 'https://httpbin.org/text/plain';
case 'options':
return {};
case 'placeholderDefinitions.values':
return [];
default:
return undefined;
}
});
const { response } = await httpTool.supplyData.call(executeFunctions, 0);
const res = await (response as N8nTool).invoke({});
expect(res).toBe('api_key: sk-live-abcdef123456');
const payload = executeFunctions.logAiEvent.mock.calls[0][1];
expect(payload).toContain('api_key: [REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
});
it('should return the response text when receiving a text response with a charset', async () => {

View File

@ -23,6 +23,8 @@ import {
} from 'n8n-workflow';
import { z } from 'zod';
import { logAiEvent, redactSecrets } from '@n8n/ai-utilities';
import type {
ParameterInputType,
ParametersValues,
@ -195,32 +197,6 @@ const defaultOptimizer = <T>(response: T) => {
/** Error payloads are appended to the tool output, so they must not flood the model's context. */
const MAX_ERROR_BODY_LENGTH = 2000;
const SECRET_REDACTION = '[redacted]';
/**
* Masks credential-shaped values that an API echoed back in its error payload, so they do not
* reach the model or the stored execution data. The key is kept, so `invalid api_key: <secret>`
* still tells the model which credential the API rejected.
*
* A sibling of the redaction applied to skill tool output in
* `packages/@n8n/agents/src/skills/tools.ts`; kept separate because `@n8n/agents` is not a
* dependency here. Keep the two in mind when changing either.
*
* The leading `[\w-]*` matters: `\b` alone never fires inside a compound key such as
* `client_secret`, where every character before `secret` is a word character. The auth scheme is
* optional so an `Authorization` header holding a bare key is masked too.
*/
const redactSecrets = (content: string): string =>
content
.replace(
/\b(authorization)(["']?\s*[:=]\s*["']?\s*(?:(?:bearer|basic)\s+)?)[^\s"',;}]+/gi,
`$1$2${SECRET_REDACTION}`,
)
.replace(
/([\w-]*(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|passwd|secret|credential|private[_-]?key))(["']?\s*[:=]\s*)(["']?)[^\s"',;}]+\3/gi,
`$1$2$3${SECRET_REDACTION}$3`,
);
type FailedRequest = {
error?: unknown;
response?: { status?: number; data?: unknown };
@ -923,6 +899,8 @@ export const configureToolFunction = (
void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]);
}
logAiEvent(ctx, 'ai-tool-called', { query, response });
return response;
};
};

View File

@ -4,13 +4,23 @@ import {
type ISupplyDataFunctions,
type IExecuteFunctions,
type INodeExecutionData,
type ExecuteWorkflowData,
type IWorkflowDataProxyData,
type WorkflowExecuteMode,
} from 'n8n-workflow';
import { mock } from 'vitest-mock-extended';
import * as manual from 'n8n-nodes-base/dist/nodes/Set/v2/manual.mode';
import { ToolWorkflow } from './ToolWorkflow.node';
import type { ToolWorkflowV1 } from './v1/ToolWorkflowV1.node';
import type { ToolWorkflowV2 } from './v2/ToolWorkflowV2.node';
import { WorkflowToolService } from './v2/utils/WorkflowToolService';
vi.mock('n8n-nodes-base/dist/nodes/Set/v2/manual.mode', () => ({
execute: vi.fn().mockResolvedValue({ json: { query: 'hello' } }),
}));
describe('ToolWorkflowV2', () => {
describe('supplyData', () => {
beforeEach(() => {
@ -215,3 +225,85 @@ describe('ToolWorkflowV2', () => {
});
});
});
describe('ToolWorkflowV1', () => {
beforeEach(() => {
vi.mocked(manual.execute).mockResolvedValue({ json: { query: 'hello' } });
});
const createContext = () => {
const logAiEvent = vi.fn();
const ctx = mock<ISupplyDataFunctions>({
getNode: vi.fn(() => mock<INode>({ typeVersion: 1.3, name: 'test tool' })),
getNodeParameter: vi.fn().mockImplementation((paramName, _itemIndex, fallback) => {
switch (paramName) {
case 'name':
return 'test_tool';
case 'description':
return 'description text';
case 'specifyInputSchema':
return false;
case 'source':
return 'database';
case 'workflowId':
return { value: 'wf-id' };
default:
return fallback;
}
}),
getWorkflowDataProxy: vi.fn(
() =>
({
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
}) as unknown as IWorkflowDataProxyData,
),
getMode: vi.fn((): WorkflowExecuteMode => 'manual'),
addInputData: vi.fn(() => ({ index: 0 })),
addOutputData: vi.fn(),
executeWorkflow: vi.fn(),
logAiEvent,
});
return { ctx, logAiEvent };
};
it('should emit ai-tool-called on successful invocation', async () => {
const { ctx, logAiEvent } = createContext();
const mockExecuteWorkflowResponse: ExecuteWorkflowData = {
data: [[{ json: { result: 'ok' } }]],
executionId: 'test-execution',
};
vi.spyOn(ctx, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse);
const toolWorkflowNode = new ToolWorkflow();
const node = toolWorkflowNode.nodeVersions[1.3] as ToolWorkflowV1;
const supplyDataResult = await node.supplyData.call(ctx, 0);
const tool = supplyDataResult.response as DynamicTool;
const expectedResponse = JSON.stringify({ result: 'ok' }, null, 2);
await expect(tool.func('hello')).resolves.toBe(expectedResponse);
expect(logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({ query: 'hello', response: expectedResponse }),
);
});
it('should emit ai-tool-called when the sub-workflow fails', async () => {
const { ctx, logAiEvent } = createContext();
vi.spyOn(ctx, 'executeWorkflow').mockRejectedValueOnce(new Error('Workflow execution failed'));
const toolWorkflowNode = new ToolWorkflow();
const node = toolWorkflowNode.nodeVersions[1.3] as ToolWorkflowV1;
const supplyDataResult = await node.supplyData.call(ctx, 0);
const tool = supplyDataResult.response as DynamicTool;
const result = await tool.func('hello');
expect(result).toContain('There was an error');
expect(result).toContain('Workflow execution failed');
expect(logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({ query: 'hello', response: result }),
);
});
});

View File

@ -22,6 +22,8 @@ import type {
} from 'n8n-workflow';
import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow';
import { logAiEvent } from '@n8n/ai-utilities';
import { versionDescription } from './versionDescription';
import type { DynamicZodObject } from '../../../../types/zod.types';
import { convertJsonSchemaToZod, generateSchemaFromExample } from '../../../../utils/schemaParsing';
@ -197,6 +199,7 @@ export class ToolWorkflowV1 implements INodeType {
const json = jsonParse<IDataObject>(response, { fallbackValue: { response } });
void this.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json }]], metadata);
}
logAiEvent(this, 'ai-tool-called', { query, response });
return response;
};

View File

@ -46,6 +46,7 @@ function createMockContext(overrides?: Partial<ISupplyDataFunctions>): ISupplyDa
executeWorkflow: vi.fn(),
addInputData: vi.fn(),
addOutputData: vi.fn(),
logAiEvent: vi.fn(),
getCredentials: vi.fn(),
getCredentialsProperties: vi.fn(),
getInputData: vi.fn(),
@ -126,6 +127,13 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result).toBe(JSON.stringify(TEST_RESPONSE, null, 2));
expect(context.addOutputData).toHaveBeenCalled();
expect(context.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: JSON.stringify(TEST_RESPONSE, null, 2),
}),
);
// Here we validate that the runIndex is correctly updated
expect(context.cloneWith).toHaveBeenCalledWith({
@ -140,6 +148,37 @@ describe('WorkflowTool::WorkflowToolService', () => {
});
});
it('should sanitize credential-shaped values in the tool-called event', async () => {
const TEST_RESPONSE = { api_key: 'sk-live-abcdef123456' };
const mockExecuteWorkflowResponse: ExecuteWorkflowData = {
data: [[{ json: TEST_RESPONSE }]],
executionId: 'test-execution',
};
vi.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse);
vi.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
vi.spyOn(context, 'getNodeParameter').mockReturnValue('database');
vi.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
} as unknown as IWorkflowDataProxyData);
vi.spyOn(context, 'cloneWith').mockReturnValue(context);
const tool = await service.createTool({
ctx: context,
name: 'TestTool',
description: 'Test Description',
itemIndex: 0,
});
const result = await tool.func('test query');
expect(result).toContain('sk-live-abcdef123456');
const payload = vi.mocked(context.logAiEvent).mock.calls[0][1];
expect(payload).toContain('[REDACTED]');
expect(payload).not.toContain('sk-live-abcdef123456');
});
it('returns un-stringified data if manualLogging is false (meaning it was called from the engine)', async () => {
const TEST_RESPONSE = { msg: 'test response' };
@ -167,6 +206,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
const result = await tool.func('test query');
expect(result).toEqual([{ json: TEST_RESPONSE }]);
expect(context.logAiEvent).not.toHaveBeenCalled();
});
it('should handle errors during tool execution', async () => {
@ -182,6 +222,10 @@ describe('WorkflowTool::WorkflowToolService', () => {
);
vi.spyOn(context, 'addInputData').mockReturnValue({ index: 0 });
vi.spyOn(context, 'getNodeParameter').mockReturnValue('database');
vi.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({
$execution: { id: 'exec-id' },
$workflow: { id: 'workflow-id' },
} as unknown as IWorkflowDataProxyData);
vi.spyOn(context, 'cloneWith').mockReturnValue(context);
const tool = await service.createTool(toolParams);
@ -189,6 +233,13 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result).toContain('There was an error');
expect(context.addOutputData).toHaveBeenCalled();
expect(context.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: 'There was an error: "Workflow execution failed"',
}),
);
});
it('should throw on tool error when manualLogging is false so the engine records the failure', async () => {
@ -214,6 +265,7 @@ describe('WorkflowTool::WorkflowToolService', () => {
const tool = await service.createTool(toolParams);
await expect(tool.func('test query')).rejects.toThrow(/Workflow execution failed/);
expect(context.logAiEvent).not.toHaveBeenCalled();
});
});
@ -874,6 +926,43 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(executeWorkflowMock).not.toHaveBeenCalled();
expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(1);
expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: 'There was an error: "Execution was cancelled"',
}),
);
});
it('should not emit ai-tool-called when cancelled and manualLogging is false', async () => {
const executeWorkflowMock = vi.fn().mockResolvedValue({
data: [[{ json: { result: 'success' } }]],
executionId: 'success-exec-id',
});
abortController.abort();
const contextWithRetryNode = createAbortSignalContext(
executeWorkflowMock,
abortController.signal,
);
service = new WorkflowToolService(contextWithRetryNode);
const tool = await service.createTool({
ctx: contextWithRetryNode,
name: 'Test Tool',
description: 'Test Description',
itemIndex: 0,
manualLogging: false,
});
const result = await tool.func('test query');
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(executeWorkflowMock).not.toHaveBeenCalled();
expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled();
});
it('should handle abort signal during retry wait', async () => {
@ -905,6 +994,55 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(sleepMock).toHaveBeenCalledWith(100, abortController.signal);
expect(executeWorkflowMock).toHaveBeenCalledTimes(1); // Only first attempt
expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(2);
expect(contextWithRetryNode.logAiEvent).toHaveBeenNthCalledWith(
1,
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: 'There was an error: "First attempt fails"',
}),
);
expect(contextWithRetryNode.logAiEvent).toHaveBeenNthCalledWith(
2,
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: 'There was an error: "Execution was cancelled"',
}),
);
});
it('should not emit ai-tool-called when retry wait is cancelled and manualLogging is false', async () => {
sleepMock.mockRejectedValue(new Error('Execution was cancelled'));
const executeWorkflowMock = vi
.fn()
.mockRejectedValueOnce(new Error('First attempt fails'))
.mockResolvedValueOnce({
data: [[{ json: { result: 'success' } }]],
executionId: 'success-exec-id',
});
const contextWithRetryNode = createAbortSignalContext(
executeWorkflowMock,
abortController.signal,
);
service = new WorkflowToolService(contextWithRetryNode);
const tool = await service.createTool({
ctx: contextWithRetryNode,
name: 'Test Tool',
description: 'Test Description',
itemIndex: 0,
manualLogging: false,
});
const result = await tool.func('test query');
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled();
});
it('should handle abort signal during execution', async () => {
@ -931,6 +1069,41 @@ describe('WorkflowTool::WorkflowToolService', () => {
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledTimes(1);
expect(contextWithRetryNode.logAiEvent).toHaveBeenCalledWith(
'ai-tool-called',
JSON.stringify({
query: 'test query',
response: 'There was an error: "Execution was cancelled"',
}),
);
});
it('should not emit ai-tool-called when execution is cancelled and manualLogging is false', async () => {
const executeWorkflowMock = vi.fn().mockImplementation(() => {
abortController.abort();
throw new UnexpectedError('Workflow execution failed');
});
const contextWithRetryNode = createAbortSignalContext(
executeWorkflowMock,
abortController.signal,
);
service = new WorkflowToolService(contextWithRetryNode);
const tool = await service.createTool({
ctx: contextWithRetryNode,
name: 'Test Tool',
description: 'Test Description',
itemIndex: 0,
manualLogging: false,
});
const result = await tool.func('test query');
expect(result).toBe('There was an error: "Execution was cancelled"');
expect(executeWorkflowMock).toHaveBeenCalledTimes(1);
expect(contextWithRetryNode.logAiEvent).not.toHaveBeenCalled();
});
it('should complete successfully if not aborted', async () => {

View File

@ -26,7 +26,7 @@ import {
parseErrorMetadata,
} from 'n8n-workflow';
import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities';
import { createZodSchemaFromArgs, extractFromAIParameters, logAiEvent } from '@n8n/ai-utilities';
import { sleep } from '@n8n/utils/sleep';
function isNodeExecutionData(data: unknown): data is INodeExecutionData[] {
@ -95,6 +95,21 @@ export class WorkflowToolService {
}
let lastError: ExecutionError | undefined;
const cancelledResponse = 'There was an error: "Execution was cancelled"';
const emitCancelledToolCall = (
loggingContext: ISupplyDataFunctions | IExecuteFunctions,
outputRunIndex: number,
) => {
if (!manualLogging) {
return;
}
const cancelledData: INodeExecutionData[] = [{ json: { error: cancelledResponse } }];
void loggingContext.addOutputData(NodeConnectionTypes.AiTool, outputRunIndex, [
cancelledData,
]);
logAiEvent(loggingContext, 'ai-tool-called', { query, response: cancelledResponse });
};
for (let tryIndex = 0; tryIndex < maxTries; tryIndex++) {
const localRunIndex = runIndex++;
@ -115,7 +130,8 @@ export class WorkflowToolService {
// Check if execution was cancelled before retry
if (abortSignal?.aborted) {
return 'There was an error: "Execution was cancelled"';
emitCancelledToolCall(context, localRunIndex);
return cancelledResponse;
}
if (tryIndex !== 0) {
@ -125,7 +141,8 @@ export class WorkflowToolService {
try {
await sleep(waitBetweenTries, abortSignal);
} catch (abortError) {
return 'There was an error: "Execution was cancelled"';
emitCancelledToolCall(context, localRunIndex);
return cancelledResponse;
}
}
}
@ -167,7 +184,7 @@ export class WorkflowToolService {
[responseData],
metadata,
);
logAiEvent(context, 'ai-tool-called', { query, response: processedResponse });
return processedResponse;
}
// If manualLogging is false we've been called by the engine and need
@ -181,7 +198,8 @@ export class WorkflowToolService {
} catch (error) {
// Check if error is due to cancellation
if (abortSignal?.aborted) {
return 'There was an error: "Execution was cancelled"';
emitCancelledToolCall(context, localRunIndex);
return cancelledResponse;
}
const executionError = error as ExecutionError;
@ -199,6 +217,7 @@ export class WorkflowToolService {
[errorData],
metadata,
);
logAiEvent(context, 'ai-tool-called', { query, response: errorResponse });
}
if (tryIndex === maxTries - 1) {