import type { DynamicStructuredTool, Tool } from '@langchain/classic/tools'; import isObject from 'lodash/isObject'; import omit from 'lodash/omit'; import type { EngineRequest, IDataObject } from 'n8n-workflow'; import { NodeConnectionTypes } from 'n8n-workflow'; import type { HitlMetadata, RequestResponseMetadata, ThinkingMetadata, ToolCallRequest, ToolMetadata, } from './types'; import { isGeminiThoughtSignatureBlock, isRedactedThinkingBlock, isThinkingBlock } from './types'; export function hasGatedToolNodeName( metadata: unknown, ): metadata is ToolMetadata & { gatedToolNodeName: string } { return ( isObject(metadata) && typeof (metadata as Record).gatedToolNodeName === 'string' ); } export function extractHitlMetadata( metadata: ToolMetadata, toolName: string, toolInput: IDataObject, ): HitlMetadata | undefined { if (!hasGatedToolNodeName(metadata)) return undefined; return { gatedToolNodeName: metadata.gatedToolNodeName, toolName, originalInput: toolInput.toolParameters as IDataObject, }; } /** * Extracts thinking metadata from tool call, with fallback to shared batch data. * Handles both Gemini thought signatures and Anthropic thinking blocks. */ function extractThinkingMetadata( toolCall: ToolCallRequest, sharedMessageLog: unknown[] | undefined, sharedAdditionalKwargs: Record | undefined, ): ThinkingMetadata { const result: ThinkingMetadata = {}; // Use toolCall's additionalKwargs or fall back to shared one from batch const effectiveAdditionalKwargs = (toolCall.additionalKwargs as Record | undefined) ?? sharedAdditionalKwargs; // Use toolCall's messageLog or fall back to shared one from batch const effectiveMessageLog = toolCall.messageLog && toolCall.messageLog.length > 0 ? toolCall.messageLog : sharedMessageLog; // Extract thought signatures from additionalKwargs (Gemini) let thoughtSignature: string | undefined; if (effectiveAdditionalKwargs) { // Check for signature mapped by tool call ID const geminiSignatures = effectiveAdditionalKwargs[ '__gemini_function_call_thought_signatures__' ] as Record | undefined; if (geminiSignatures && typeof geminiSignatures === 'object') { // Get signature for this specific tool call, or ANY signature if ID not found // (for parallel calls, signature may be keyed to first call's ID) thoughtSignature = geminiSignatures[toolCall.toolCallId] || Object.values(geminiSignatures)[0]; } // Also check signatures array format (LangChain Google uses this) if (!thoughtSignature) { const signatures = effectiveAdditionalKwargs.signatures as string[] | undefined; if (signatures && Array.isArray(signatures) && signatures.length > 0) { // First non-empty signature (parallel calls have signature only on first) thoughtSignature = signatures.find((s) => s && s.length > 0); } } } // Extract thinking content and additional thought signatures from messageLog let thinkingContent: string | undefined; let thinkingType: 'thinking' | 'redacted_thinking' | undefined; let thinkingSignature: string | undefined; if (effectiveMessageLog && Array.isArray(effectiveMessageLog)) { for (const message of effectiveMessageLog) { // Check if message has content that could contain thought_signature or thinking blocks if (message && typeof message === 'object' && 'content' in message) { const content = message.content; // Content can be string or array of content blocks if (Array.isArray(content)) { // Look for thought_signature in content blocks (Gemini) // and thinking/redacted_thinking blocks (Anthropic) for (const block of content) { // Gemini thought_signature as content block (only if not already found) if (!thoughtSignature && isGeminiThoughtSignatureBlock(block)) { thoughtSignature = block.thoughtSignature; } // Anthropic thinking blocks if (isThinkingBlock(block)) { thinkingContent = block.thinking; thinkingType = 'thinking'; thinkingSignature = block.signature; } else if (isRedactedThinkingBlock(block)) { thinkingContent = block.data; thinkingType = 'redacted_thinking'; } } } // Also check additional_kwargs on the message for Gemini thought signatures if (!thoughtSignature && 'additional_kwargs' in message) { const msgAdditionalKwargs = message.additional_kwargs as | Record | undefined; if (msgAdditionalKwargs) { // First check the map format: __gemini_function_call_thought_signatures__ const geminiSignatures = msgAdditionalKwargs[ '__gemini_function_call_thought_signatures__' ] as Record | undefined; if (geminiSignatures && typeof geminiSignatures === 'object') { // Get signature for this tool call, or ANY signature for parallel calls thoughtSignature = geminiSignatures[toolCall.toolCallId] || Object.values(geminiSignatures)[0]; } // If not found, check the signatures array format // LangChain Google returns signatures as an array that corresponds to tool_calls array if (!thoughtSignature) { const signatures = msgAdditionalKwargs.signatures as string[] | undefined; // Get tool_calls from message (not from additional_kwargs) const msgToolCalls = 'tool_calls' in message ? (message.tool_calls as Array<{ id?: string }> | undefined) : undefined; if (signatures && Array.isArray(signatures)) { if (msgToolCalls && Array.isArray(msgToolCalls)) { // Find the index of this tool call by ID const toolCallIndex = msgToolCalls.findIndex( (tc) => tc.id === toolCall.toolCallId, ); if (toolCallIndex > 0 && toolCallIndex < signatures.length) { thoughtSignature = signatures[toolCallIndex]; } } // Fallback: get first non-empty signature thoughtSignature ??= signatures.find((s) => s && s.length > 0); } } } } if (thoughtSignature || thinkingContent) break; } } } // Build result object if (thoughtSignature) { result.google = { thoughtSignature }; } if (thinkingContent && thinkingType) { result.anthropic = { thinkingContent, thinkingType, ...(thinkingSignature ? { thinkingSignature } : {}), }; } return result; } /** * Creates engine requests from tool calls. * Maps tool call information to the format expected by the n8n engine * for executing tool nodes. * * This is a generalized version that can be used across different agent types * (Tools Agent, OpenAI Functions Agent, etc.). * * @param toolCalls - Array of tool call requests to convert * @param itemIndex - The current item index * @param tools - Array of available tools * @returns Array of engine request objects (filtered to remove undefined entries) */ export function createEngineRequests( toolCalls: ToolCallRequest[], itemIndex: number, tools: Array, ): EngineRequest['actions'] { // For parallel tool calls, LangChain may only populate messageLog on the first action. // Find a shared messageLog to use for all tool calls in this batch. const sharedMessageLog = toolCalls.find( (tc) => tc.messageLog && tc.messageLog.length > 0, )?.messageLog; // Similarly for additionalKwargs (contains Gemini thought signatures) const sharedAdditionalKwargs = toolCalls.find((tc) => tc.additionalKwargs)?.additionalKwargs as | Record | undefined; return toolCalls .map((toolCall) => { // First try to get from metadata (for toolkit tools) const foundTool = tools.find((tool) => tool.name === toolCall.tool); if (!foundTool) return undefined; const nodeName = foundTool.metadata?.sourceNodeName; if (typeof nodeName !== 'string') return undefined; const metadata = (foundTool.metadata ?? {}) as ToolMetadata; const toolInput = toolCall.toolInput as IDataObject; const hitlMetadata = extractHitlMetadata(metadata, toolCall.tool, toolInput); let input: IDataObject = toolInput; if (metadata.isFromToolkit) { input = { ...input, tool: toolCall.tool }; } if (hitlMetadata) { // This input will be used as HITL node input input = { // omit hitlParameters, because they are destructured into the input instead ...omit(input, 'hitlParameters'), ...(input.hitlParameters as IDataObject), toolParameters: input.toolParameters, }; } return { actionType: 'ExecutionNodeAction' as const, nodeName, input, type: NodeConnectionTypes.AiTool, id: toolCall.toolCallId, metadata: { itemIndex, hitl: hitlMetadata, ...extractThinkingMetadata(toolCall, sharedMessageLog, sharedAdditionalKwargs), }, }; }) .filter((item): item is NonNullable => item !== undefined); }