mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
fix(ai-builder): Emit tool call events for Workflow, HTTP Request and Code tools (#37044)
This commit is contained in:
parent
464fb4701a
commit
7e8161043d
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
|
|
|
|||
122
packages/@n8n/ai-utilities/src/utils/redact-secrets.ts
Normal file
122
packages/@n8n/ai-utilities/src/utils/redact-secrets.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user