From 46bef9601e32d1e41aa77d879deb1af5fad5a2ac Mon Sep 17 00:00:00 2001 From: octo-patch Date: Fri, 17 Apr 2026 10:54:50 +0800 Subject: [PATCH] fix(nodes-langchain): emit ai-tool-called log event for ToolWorkflow, ToolCode, and ToolHttpRequest These three tools use a manual logging pattern (manualLogging=true) that calls addInputData/addOutputData directly, bypassing the logWrapper proxy which normally emits the ai-tool-called event for LangChain-native tools. As a result, log streaming subscribers never received n8n.ai.tool.called events when these tools executed, creating an observability blind spot for the most commonly used AI agent tools. Fix: call logAiEvent('ai-tool-called', ...) in the success branch of each tool handler function, consistent with the pattern used by logWrapper. Fixes #28532 --- .../nodes/tools/ToolCode/ToolCode.node.ts | 3 +- .../nodes/tools/ToolHttpRequest/utils.ts | 3 + .../ToolWorkflow/v2/ToolWorkflowV2.test.ts | 74 +++++++++++++++++++ .../v2/utils/WorkflowToolService.ts | 3 +- 4 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts index bad3067d40419..68dec6b07b332 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolCode/ToolCode.node.ts @@ -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'; @@ -119,6 +119,7 @@ function getTool( void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError); } else if (log) { void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); + logAiEvent(ctx, 'ai-tool-called', { query, response }); } return response; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts index e0d019e933c75..d8374b927ff8c 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolHttpRequest/utils.ts @@ -17,6 +17,8 @@ import type { import { NodeConnectionTypes, NodeOperationError, jsonParse } from 'n8n-workflow'; import { z } from 'zod'; +import { logAiEvent } from '@n8n/ai-utilities'; + import type { ParameterInputType, ParametersValues, @@ -784,6 +786,7 @@ export const configureToolFunction = ( void ctx.addOutputData(NodeConnectionTypes.AiTool, index, executionError as ExecutionError); } else { void ctx.addOutputData(NodeConnectionTypes.AiTool, index, [[{ json: { response } }]]); + logAiEvent(ctx, 'ai-tool-called', { query, response }); } return response; diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts index 0efe74236b7dd..63e1f711c365b 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/ToolWorkflowV2.test.ts @@ -8,6 +8,8 @@ import type { INode, } from 'n8n-workflow'; +import { logAiEvent } from '@n8n/ai-utilities'; + import { WorkflowToolService } from './utils/WorkflowToolService'; // Mock the sleep functions @@ -17,6 +19,11 @@ jest.mock('n8n-workflow', () => ({ sleepWithAbort: jest.fn().mockResolvedValue(undefined), })); +jest.mock('@n8n/ai-utilities', () => ({ + ...jest.requireActual('@n8n/ai-utilities'), + logAiEvent: jest.fn(), +})); + function createMockClonedContext( baseContext: ISupplyDataFunctions, executeWorkflowMock?: jest.MockedFunction, @@ -971,4 +978,71 @@ describe('WorkflowTool::WorkflowToolService', () => { expect(sleepWithAbort).toHaveBeenCalledWith(100, undefined); }); }); + + describe('logAiEvent emission', () => { + const mockLogAiEvent = logAiEvent as jest.MockedFunction; + + beforeEach(() => { + mockLogAiEvent.mockClear(); + }); + + it('should emit ai-tool-called event on successful execution with manualLogging enabled', async () => { + const TEST_RESPONSE = { msg: 'test response' }; + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + jest.spyOn(context, 'addInputData').mockReturnValue({ index: 0 }); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); + jest.spyOn(context, 'cloneWith').mockReturnValue(context); + + const tool = await service.createTool({ + ctx: context, + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + }); + + await tool.func('test query'); + + expect(mockLogAiEvent).toHaveBeenCalledWith( + expect.anything(), + 'ai-tool-called', + expect.objectContaining({ query: 'test query' }), + ); + }); + + it('should not emit ai-tool-called event when manualLogging is disabled', async () => { + const TEST_RESPONSE = { msg: 'test response' }; + const mockExecuteWorkflowResponse: ExecuteWorkflowData = { + data: [[{ json: TEST_RESPONSE }]], + executionId: 'test-execution', + }; + + jest.spyOn(context, 'executeWorkflow').mockResolvedValueOnce(mockExecuteWorkflowResponse); + jest.spyOn(context, 'getNodeParameter').mockReturnValue('database'); + jest.spyOn(context, 'getWorkflowDataProxy').mockReturnValue({ + $execution: { id: 'exec-id' }, + $workflow: { id: 'workflow-id' }, + } as unknown as IWorkflowDataProxyData); + + const tool = await service.createTool({ + ctx: context, + name: 'TestTool', + description: 'Test Description', + itemIndex: 0, + manualLogging: false, + }); + + await tool.func('test query'); + + expect(mockLogAiEvent).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts index 8946b7faf633d..6a88f65dc7fca 100644 --- a/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts +++ b/packages/@n8n/nodes-langchain/nodes/tools/ToolWorkflow/v2/utils/WorkflowToolService.ts @@ -27,7 +27,7 @@ import { sleepWithAbort, } from 'n8n-workflow'; -import { createZodSchemaFromArgs, extractFromAIParameters } from '@n8n/ai-utilities'; +import { createZodSchemaFromArgs, extractFromAIParameters, logAiEvent } from '@n8n/ai-utilities'; function isNodeExecutionData(data: unknown): data is INodeExecutionData[] { return isArray(data) && Boolean(data.length) && isObject(data[0]) && 'json' in data[0]; @@ -168,6 +168,7 @@ export class WorkflowToolService { metadata, ); + logAiEvent(context, 'ai-tool-called', { query, response: processedResponse }); return processedResponse; } // If manualLogging is false we've been called by the engine and need