first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,28 @@
import type { EngineResponse } from 'n8n-workflow';
import { buildSteps } from './buildSteps';
import type { RequestResponseMetadata } from './types';
/**
* Builds metadata for an engine request, tracking iteration count and previous requests.
*
* This helper centralizes the logic for incrementing iteration count and building
* the request history, which is used to enforce max iterations and maintain context.
*
* @param response - The optional engine response from previous tool execution
* @param itemIndex - The current item index being processed
* @returns Metadata object with previousRequests and iterationCount
*
*/
export function buildResponseMetadata(
response: EngineResponse<RequestResponseMetadata> | undefined,
itemIndex: number,
): RequestResponseMetadata {
const currentIterationCount = response?.metadata?.iterationCount ?? 0;
return {
previousRequests: buildSteps(response, itemIndex),
itemIndex,
iterationCount: currentIterationCount + 1,
};
}
@@ -0,0 +1,396 @@
import { AIMessage } from '@langchain/core/messages';
import { nodeNameToToolName } from 'n8n-workflow';
import type { EngineResponse, EngineResult, IDataObject } from 'n8n-workflow';
import type {
RequestResponseMetadata,
ToolCallData,
ThinkingContentBlock,
RedactedThinkingContentBlock,
ToolUseContentBlock,
} from './types';
/**
* Provider-specific metadata extracted from tool action metadata
*/
interface ProviderMetadata {
/** Gemini thought_signature for extended thinking */
thoughtSignature?: string;
/** Anthropic thinking content */
thinkingContent?: string;
/** Anthropic thinking type (thinking or redacted_thinking) */
thinkingType?: 'thinking' | 'redacted_thinking';
/** Anthropic thinking signature */
thinkingSignature?: string;
}
/**
* Extracts provider-specific metadata from tool action metadata.
* Validates and normalizes metadata from different LLM providers.
*
* @param metadata - The request/response metadata from tool action
* @returns Extracted and validated provider metadata
*/
function extractProviderMetadata(metadata?: RequestResponseMetadata): ProviderMetadata {
if (!metadata) return {};
// Extract Google/Gemini metadata
const thoughtSignature =
typeof metadata.google?.thoughtSignature === 'string'
? metadata.google.thoughtSignature
: undefined;
// Extract Anthropic metadata
const thinkingContent =
typeof metadata.anthropic?.thinkingContent === 'string'
? metadata.anthropic.thinkingContent
: undefined;
const thinkingType =
metadata.anthropic?.thinkingType === 'thinking' ||
metadata.anthropic?.thinkingType === 'redacted_thinking'
? metadata.anthropic.thinkingType
: undefined;
const thinkingSignature =
typeof metadata.anthropic?.thinkingSignature === 'string'
? metadata.anthropic.thinkingSignature
: undefined;
return {
thoughtSignature,
thinkingContent,
thinkingType,
thinkingSignature,
};
}
/**
* Builds Anthropic-specific content blocks for thinking mode.
* Creates an array with thinking block followed by tool_use block.
*
* IMPORTANT: The thinking block must come before tool_use in the message.
* When content is an array, LangChain ignores tool_calls field for Anthropic,
* so tool_use blocks must be in the content array.
*
* @param thinkingContent - The thinking content from Anthropic
* @param thinkingType - Type of thinking block (thinking or redacted_thinking)
* @param thinkingSignature - Optional signature for thinking block
* @param toolInput - The tool input data
* @param toolId - The tool call ID
* @param toolName - The tool name
* @returns Array of content blocks with thinking and tool_use
*/
function buildAnthropicContentBlocks(
thinkingContent: string,
thinkingType: 'thinking' | 'redacted_thinking',
thinkingSignature: string | undefined,
toolInput: IDataObject,
toolId: string,
toolName: string,
): Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
// Create thinking block with correct field names for Anthropic API
const thinkingBlock: ThinkingContentBlock | RedactedThinkingContentBlock =
thinkingType === 'thinking'
? {
type: 'thinking',
thinking: thinkingContent,
signature: thinkingSignature ?? '', // Use original signature if available
}
: {
type: 'redacted_thinking',
data: thinkingContent,
};
// Create tool_use block (required for Anthropic when using structured content)
const toolInputData = toolInput.input;
const toolUseBlock: ToolUseContentBlock = {
type: 'tool_use',
id: toolId,
name: toolName,
input:
toolInputData && typeof toolInputData === 'object'
? (toolInputData as Record<string, unknown>)
: {},
};
return [thinkingBlock, toolUseBlock];
}
/**
* Builds message content for AI message, handling provider-specific formats.
* For Anthropic thinking mode, creates content blocks with thinking and tool_use.
* For other providers, creates simple string content.
*
* @param providerMetadata - Provider-specific metadata
* @param toolInput - The tool input data
* @param toolId - The tool call ID
* @param toolName - The tool name
* @param nodeName - The node name for fallback string content
* @returns Message content (string or content blocks array)
*/
function buildMessageContent(
providerMetadata: ProviderMetadata,
toolInput: IDataObject,
toolId: string,
toolName: string,
): string | Array<ThinkingContentBlock | RedactedThinkingContentBlock | ToolUseContentBlock> {
const { thinkingContent, thinkingType, thinkingSignature } = providerMetadata;
// Anthropic thinking mode: build content blocks
if (thinkingContent && thinkingType) {
return buildAnthropicContentBlocks(
thinkingContent,
thinkingType,
thinkingSignature,
toolInput,
toolId,
toolName,
);
}
// Default: simple string content
return `Calling ${toolName} with input: ${JSON.stringify(toolInput)}`;
}
function resolveToolName(tool: EngineResult<RequestResponseMetadata>): string {
return tool.action.metadata?.hitl?.toolName ?? nodeNameToToolName(tool.action.nodeName);
}
/**
* Processed tool response data used during step building.
*/
interface ProcessedToolResponse {
tool: NonNullable<EngineResponse<RequestResponseMetadata>['actionResponses']>[number];
toolInput: IDataObject;
toolId: string;
toolName: string;
nodeName: string;
providerMetadata: ProviderMetadata;
}
/**
* Builds the additional_kwargs needed for Gemini thought signatures.
*
* The structure matches what @langchain/google-common expects:
* - `__gemini_function_call_thought_signatures__`: maps the first tool call ID to the signature
* - `tool_calls`: array of tool call descriptors
* - `signatures`: array aligned to parts [textPart, functionCall_1, ...], with the signature
* only on the first function call (per Google's docs)
*
* @param toolCalls - Tool calls to include
* @param thoughtSignature - The Gemini thought signature
* @returns additional_kwargs object for AIMessage
*/
function buildGeminiAdditionalKwargs(
toolCalls: Array<{ id: string; name: string; args: IDataObject }>,
thoughtSignature: string,
): Record<string, unknown> {
const signatures: string[] = ['', thoughtSignature];
for (let i = 2; i <= toolCalls.length; i++) {
signatures.push('');
}
return {
__gemini_function_call_thought_signatures__: {
[toolCalls[0].id]: thoughtSignature,
},
tool_calls: toolCalls.map((tc) => ({ id: tc.id, name: tc.name, args: tc.args })),
signatures,
};
}
/**
* Builds an AIMessage for a single tool call, handling provider-specific formats.
*
* For Anthropic thinking mode, content is an array of blocks (thinking + tool_use).
* For Gemini with thought signatures, additional_kwargs carries the signature.
* For other providers, content is a simple string with tool_calls set.
*/
function buildIndividualAIMessage(
toolId: string,
toolName: string,
toolInput: IDataObject,
providerMetadata: ProviderMetadata,
): AIMessage {
const toolCall = {
id: toolId,
name: toolName,
args: toolInput,
type: 'tool_call' as const,
};
const content = buildMessageContent(providerMetadata, toolInput, toolId, toolName);
return new AIMessage({
content,
// When content is an array (Anthropic thinking), LangChain ignores tool_calls
...(typeof content === 'string' && { tool_calls: [toolCall] }),
...(providerMetadata.thoughtSignature && {
additional_kwargs: buildGeminiAdditionalKwargs(
[{ id: toolId, name: toolName, args: toolInput }],
providerMetadata.thoughtSignature,
),
}),
});
}
/**
* Builds a shared AIMessage for parallel tool calls with Gemini thought signatures.
*
* For parallel function calls, Gemini requires ALL function calls in a single "model" turn
* with the thought_signature only on the first function call part. This matches the original
* model response structure and ensures the cryptographic signature validates correctly.
*
* LangChain's formatToToolMessages creates one ToolMessage per step. The @langchain/google-common
* package automatically merges consecutive "function" role messages, so the function responses
* will be correctly grouped.
*
* @param processedTools - Array of processed tool responses to include
* @param thoughtSignature - The shared thought signature from Gemini
* @returns AIMessage with all tool calls and proper signature format
*/
function buildSharedGeminiAIMessage(
processedTools: ProcessedToolResponse[],
thoughtSignature: string,
): AIMessage {
const allToolCalls = processedTools.map((pt) => ({
id: pt.toolId,
name: pt.toolName,
args: pt.toolInput,
type: 'tool_call' as const,
}));
const toolNames = processedTools.map((pt) => pt.nodeName).join(', ');
return new AIMessage({
content: `Calling tools: ${toolNames}`,
tool_calls: allToolCalls,
additional_kwargs: buildGeminiAdditionalKwargs(allToolCalls, thoughtSignature),
});
}
/**
* Builds the observation string from tool result data.
*/
function buildObservation(toolData: {
data?: { ai_tool?: Array<Array<{ json?: unknown }>> };
error?: { message?: string; name?: string };
}): string {
const aiToolData = toolData?.data?.ai_tool?.[0]?.map((item) => item?.json);
if (aiToolData && aiToolData.length > 0) {
return JSON.stringify(aiToolData);
}
if (toolData?.error) {
const errorInfo = {
error: toolData.error.message ?? 'Unknown error',
...(toolData.error.name && { errorType: toolData.error.name }),
};
return JSON.stringify(errorInfo);
}
return JSON.stringify('');
}
/**
* Rebuilds the agent steps from previous tool call responses.
* This is used to continue agent execution after tool calls have been made.
*
* For parallel tool calls with Gemini thought signatures, all function calls are grouped
* into a single AIMessage to match the original model response structure. This is required
* because the thought_signature is cryptographically tied to the combined parallel call turn.
*
* This is a generalized version that can be used across different agent types
* (Tools Agent, OpenAI Functions Agent, etc.).
*
* @param response - The engine response containing tool call results
* @param itemIndex - The current item index being processed
* @returns Array of tool call data representing the agent steps
*/
export function buildSteps(
response: EngineResponse<RequestResponseMetadata> | undefined,
itemIndex: number,
): ToolCallData[] {
const steps: ToolCallData[] = [];
if (!response) return steps;
const responses = response.actionResponses ?? [];
if (response.metadata?.previousRequests) {
steps.push(...response.metadata.previousRequests);
}
// First pass: collect all valid tool responses for this batch
const batchTools: ProcessedToolResponse[] = [];
for (const tool of responses) {
if (tool.action?.metadata?.itemIndex !== itemIndex) continue;
const toolInput: IDataObject = {
...tool.action.input,
id: tool.action.id,
};
if (!tool.data) continue;
const existingStep = steps.find((s) => s.action.toolCallId === toolInput.id);
if (existingStep) continue;
const providerMetadata = extractProviderMetadata(tool.action.metadata);
const toolId = typeof toolInput?.id === 'string' ? toolInput.id : 'reconstructed_call';
const toolName = resolveToolName(tool);
batchTools.push({
tool,
toolInput,
toolId,
toolName,
nodeName: tool.action.nodeName,
providerMetadata,
});
}
// Check if this batch has Gemini thought signatures and multiple parallel tool calls.
// If so, we must group them into a single AIMessage because:
// 1. The thought_signature is cryptographically tied to the combined parallel call turn
// 2. Splitting into separate model turns invalidates the signature
// 3. Google's API requires matching function response parts per function call turn
const sharedThoughtSignature = batchTools.find((bt) => bt.providerMetadata.thoughtSignature)
?.providerMetadata.thoughtSignature;
const sharedAIMessage =
sharedThoughtSignature && batchTools.length > 1
? buildSharedGeminiAIMessage(batchTools, sharedThoughtSignature)
: undefined;
// Second pass: build steps
for (let i = 0; i < batchTools.length; i++) {
const { tool, toolInput, toolId, toolName, nodeName, providerMetadata } = batchTools[i];
const observation = buildObservation(tool.data);
// Exclude metadata fields (id, log, type) from the tool input forwarded to the result
const { id, log, type, ...toolInputForResult } = toolInput;
// Parallel Gemini tool calls: first step gets the shared AIMessage,
// subsequent steps get empty messageLog. LangChain's formatToToolMessages
// will produce: [SharedAIMessage, ToolMsg_1, ToolMsg_2, ...]
const messageLog = sharedAIMessage
? i === 0
? [sharedAIMessage]
: []
: [buildIndividualAIMessage(toolId, toolName, toolInput, providerMetadata)];
steps.push({
action: {
tool: toolName,
toolInput: toolInputForResult,
log: toolInput.log || (messageLog[0]?.content ?? `Calling ${nodeName}`),
messageLog,
toolCallId: toolInput?.id,
type: toolInput.type || 'tool_call',
},
observation,
});
}
return steps;
}
@@ -0,0 +1,248 @@
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<string, unknown>).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<string, unknown> | undefined,
): ThinkingMetadata {
const result: ThinkingMetadata = {};
// Use toolCall's additionalKwargs or fall back to shared one from batch
const effectiveAdditionalKwargs =
(toolCall.additionalKwargs as Record<string, unknown> | 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<string, string> | 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<string, unknown>
| undefined;
if (msgAdditionalKwargs) {
// First check the map format: __gemini_function_call_thought_signatures__
const geminiSignatures = msgAdditionalKwargs[
'__gemini_function_call_thought_signatures__'
] as Record<string, string> | 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<DynamicStructuredTool | Tool>,
): EngineRequest<RequestResponseMetadata>['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<string, unknown>
| 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<typeof item> => item !== undefined);
}
@@ -0,0 +1,28 @@
/**
* Agent Execution Utilities
*
* This module contains generalized utilities for agent execution that can be
* reused across different agent types (Tools Agent, OpenAI Functions Agent, etc.).
*
* These utilities support engine-based tool execution, where tool calls are
* delegated to the n8n workflow engine instead of being executed inline.
*/
export { createEngineRequests } from './createEngineRequests';
export { buildResponseMetadata } from './buildResponseMetadata';
export { buildSteps } from './buildSteps';
export { processEventStream } from './processEventStream';
export { loadMemory, saveToMemory, buildToolContext } from './memoryManagement';
export { processHitlResponses, type HitlProcessingResult } from './processHitlResponses';
export { serializeIntermediateSteps } from './serializeIntermediateSteps';
export type {
ToolCallRequest,
ToolCallData,
AgentResult,
RequestResponseMetadata,
ToolMetadata,
ThinkingMetadata,
GoogleThinkingMetadata,
AnthropicThinkingMetadata,
HitlMetadata,
} from './types';
@@ -0,0 +1,305 @@
import type { BaseChatMemory } from '@langchain/classic/memory';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { BaseMessage } from '@langchain/core/messages';
import { AIMessage, HumanMessage, ToolMessage, trimMessages } from '@langchain/core/messages';
import type { IDataObject, GenericValue } from 'n8n-workflow';
import type { ToolCallData } from './types';
/**
* Extracts a string tool_call_id from various possible formats.
* Handles the complex type: IDataObject | GenericValue | GenericValue[] | IDataObject[]
*
* @param toolCallId - The tool call ID in various possible formats
* @param toolName - The tool name, used for generating synthetic IDs
* @returns A valid string tool_call_id
*
* @example
* ```typescript
* extractToolCallId('call-123', 'calculator') // Returns: 'call-123'
* extractToolCallId({ id: 'call-456' }, 'search') // Returns: 'call-456'
* extractToolCallId(['call-789'], 'weather') // Returns: 'call-789'
* extractToolCallId(null, 'unknown') // Returns: 'synthetic_unknown_1234567890'
* ```
*/
export function extractToolCallId(
toolCallId: IDataObject | GenericValue | GenericValue[] | IDataObject[],
toolName: string,
): string {
// Case 1: Already a string
if (typeof toolCallId === 'string' && toolCallId.length > 0) {
return toolCallId;
}
// Case 2: Object with 'id' property
if (
typeof toolCallId === 'object' &&
toolCallId !== null &&
!Array.isArray(toolCallId) &&
'id' in toolCallId
) {
const id = toolCallId.id;
if (typeof id === 'string' && id.length > 0) {
return id;
}
}
// Case 3: Array - recursively extract from first element
if (Array.isArray(toolCallId) && toolCallId.length > 0) {
return extractToolCallId(toolCallId[0], toolName);
}
// Fallback: Generate synthetic ID
return `synthetic_${toolName}_${Date.now()}`;
}
/**
* Converts ToolCallData array into LangChain message sequence.
* Creates alternating AIMessage (with tool_calls) and ToolMessage pairs.
*
* @param steps - Array of tool call data with actions and observations
* @returns Array of BaseMessage objects (AIMessage and ToolMessage pairs)
*
* @example
* ```typescript
* const messages = buildMessagesFromSteps([{
* action: {
* tool: 'calculator',
* toolInput: { expression: '2+2' },
* messageLog: [aiMessageWithToolCalls],
* toolCallId: 'call-123'
* },
* observation: '4'
* }]);
* // Returns: [AIMessage with tool_calls, ToolMessage with result]
* ```
*/
export function buildMessagesFromSteps(steps: ToolCallData[]): BaseMessage[] {
const messages: BaseMessage[] = [];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
// Try to extract existing AIMessage and its tool_call ID
const existingAIMessage = step.action.messageLog?.[0];
const existingToolCallId = existingAIMessage?.tool_calls?.[0]?.id;
// Use existing ID if available, otherwise extract from step data
const toolCallId =
existingToolCallId ?? extractToolCallId(step.action.toolCallId, step.action.tool);
// Use existing AIMessage or create a synthetic one
const aiMessage =
existingAIMessage ??
new AIMessage({
content: `Calling ${step.action.tool} with input: ${JSON.stringify(step.action.toolInput)}`,
tool_calls: [
{
id: toolCallId,
name: step.action.tool,
args: step.action.toolInput,
type: 'tool_call',
},
],
});
// Create ToolMessage with the observation result
const toolMessage = new ToolMessage({
content: step.observation,
tool_call_id: toolCallId,
name: step.action.tool,
});
// Add both messages
messages.push(aiMessage);
messages.push(toolMessage);
}
return messages;
}
/**
* Builds a formatted string representation of tool calls for memory storage.
* This creates a consistent format that can be used across both streaming and non-streaming modes.
*
* @deprecated Used only as fallback for custom memory implementations that don't support addMessages
* @param steps - Array of tool call data with actions and observations
* @returns Formatted string of tool calls separated by semicolons
*
* @example
* ```typescript
* const context = buildToolContext([{
* action: { tool: 'calculator', toolInput: { expression: '2+2' }, ... },
* observation: '4'
* }]);
* // Returns: "Tool: calculator, Input: {"expression":"2+2"}, Result: 4"
* ```
*/
export function buildToolContext(steps: ToolCallData[]): string {
return steps
.map(
(step) =>
`Tool: ${step.action.tool}, Input: ${JSON.stringify(step.action.toolInput)}, Result: ${step.observation}`,
)
.join('; ');
}
/**
* Removes orphaned ToolMessages and AIMessages with tool_calls from the start of chat history.
* This happens when memory trimming cuts messages mid-turn, leaving incomplete tool call sequences.
*
* @param chatHistory - Array of messages to clean up
* @returns Cleaned array with orphaned messages removed from the start
*/
function cleanupOrphanedMessages(chatHistory: BaseMessage[]): BaseMessage[] {
let changed = true;
while (changed && chatHistory.length > 0) {
changed = false;
// Remove orphaned ToolMessages at the start
while (chatHistory.length > 0 && chatHistory[0] instanceof ToolMessage) {
chatHistory.shift();
changed = true;
}
// Remove AIMessages with tool_calls if they don't have following ToolMessages
if (chatHistory.length > 0) {
const firstMessage = chatHistory[0];
const hasOrphanedAIMessage =
firstMessage instanceof AIMessage &&
(firstMessage.tool_calls?.length ?? 0) > 0 &&
!(chatHistory[1] instanceof ToolMessage);
if (hasOrphanedAIMessage) {
chatHistory.shift();
changed = true;
}
}
}
return chatHistory;
}
/**
* Loads chat history from memory and optionally trims it to fit within token limits.
* Automatically cleans up orphaned tool messages that may result from memory trimming.
*
* @param memory - The memory instance to load from
* @param model - Optional chat model for token counting (required if maxTokens is specified)
* @param maxTokens - Optional maximum number of tokens to load from memory
* @returns Array of base messages representing the chat history
*
* @example
* ```typescript
* // Load all history
* const messages = await loadMemory(memory);
*
* // Load with token limit
* const messages = await loadMemory(memory, model, 2000);
* ```
*/
export async function loadMemory(
memory?: BaseChatMemory,
model?: BaseChatModel,
maxTokens?: number,
): Promise<BaseMessage[] | undefined> {
if (!memory) {
return undefined;
}
const memoryVariables = await memory.loadMemoryVariables({});
let chatHistory = (memoryVariables['chat_history'] as BaseMessage[]) || [];
// Clean up any orphaned messages from previous trimming operations
chatHistory = cleanupOrphanedMessages(chatHistory);
// Trim messages if token limit is specified and model is available
if (maxTokens && model) {
chatHistory = await trimMessages(chatHistory, {
strategy: 'last',
maxTokens,
tokenCounter: model,
includeSystem: true,
startOn: 'human',
allowPartial: true,
});
// Clean up again after trimming, as it may create new orphans
chatHistory = cleanupOrphanedMessages(chatHistory);
}
return chatHistory;
}
/**
* Saves a conversation turn (user input + agent output) to memory.
* Uses LangChain-native message types (AIMessage with tool_calls, ToolMessage)
* when tools are involved, preserving semantic structure for LLMs.
*
* @param input - The user input/prompt
* @param output - The agent's output/response
* @param memory - The memory instance to save to
* @param steps - Optional tool call data to save as proper message sequence
* @param previousStepsCount - Number of steps from previous turns (to filter out duplicates)
*
* @example
* ```typescript
* // Simple conversation (no tools)
* await saveToMemory('What is 2+2?', 'The answer is 4', memory);
*
* // With tool calls (saves full message sequence)
* await saveToMemory('Calculate 2+2', 'The answer is 4', memory, steps, 0);
* ```
*/
export async function saveToMemory(
input: string,
output: string,
memory?: BaseChatMemory,
steps?: ToolCallData[],
previousStepsCount?: number,
): Promise<void> {
if (!output || !memory) {
return;
}
// No tool calls: use simple saveContext (backwards compatible)
if (!steps || steps.length === 0) {
await memory.saveContext({ input }, { output });
return;
}
// Filter out previous steps to avoid duplicates (they're already in memory)
const newSteps = previousStepsCount ? steps.slice(previousStepsCount) : steps;
if (newSteps.length === 0) {
await memory.saveContext({ input }, { output });
return;
}
// Check if memory supports addMessages (feature detection)
if (
!('addMessages' in memory.chatHistory) ||
typeof memory.chatHistory.addMessages !== 'function'
) {
// Fallback: use old string format (only with new steps to avoid duplicates)
const toolContext = buildToolContext(newSteps);
const fullOutput = `[Used tools: ${toolContext}] ${output}`;
await memory.saveContext({ input }, { output: fullOutput });
return;
}
// Build full conversation sequence using LangChain-native message types
const messages: BaseMessage[] = [];
// 1. User input
messages.push(new HumanMessage(input));
// 2. Tool call sequence (AIMessage with tool_calls → ToolMessage for each)
const toolMessages = buildMessagesFromSteps(newSteps);
messages.push.apply(messages, toolMessages);
// 3. Final AI response (no tool_calls)
messages.push(new AIMessage(output));
// 4. Save all messages in bulk
await memory.chatHistory.addMessages(messages);
}
@@ -0,0 +1,94 @@
import type { StreamEvent } from '@langchain/core/dist/tracers/event_stream';
import type { IterableReadableStream } from '@langchain/core/dist/utils/stream';
import type { AIMessageChunk, MessageContentText } from '@langchain/core/messages';
import type { IExecuteFunctions } from 'n8n-workflow';
import type { AgentResult, ToolCallRequest } from './types';
/**
* Processes the event stream from a streaming agent execution.
* Handles streaming chunks, tool calls, and intermediate steps.
*
* This is a generalized version that can be used across different agent types
* (Tools Agent, OpenAI Functions Agent, etc.).
*
* @param ctx - The execution context
* @param eventStream - The stream of events from the agent
* @param itemIndex - The current item index
* @returns AgentResult containing output and optional tool calls/steps
*/
export async function processEventStream(
ctx: IExecuteFunctions,
eventStream: IterableReadableStream<StreamEvent>,
itemIndex: number,
): Promise<AgentResult> {
const agentResult: AgentResult = {
output: '',
};
const toolCalls: ToolCallRequest[] = [];
ctx.sendChunk('begin', itemIndex);
for await (const event of eventStream) {
// Stream chat model tokens as they come in
switch (event.event) {
case 'on_chat_model_stream':
const chunk = event.data?.chunk as AIMessageChunk;
if (chunk?.content) {
const chunkContent = chunk.content;
let chunkText = '';
if (Array.isArray(chunkContent)) {
for (const message of chunkContent) {
if (message?.type === 'text') {
chunkText += (message as MessageContentText)?.text;
}
}
} else if (typeof chunkContent === 'string') {
chunkText = chunkContent;
}
ctx.sendChunk('item', itemIndex, chunkText);
agentResult.output += chunkText;
}
break;
case 'on_chat_model_end':
// Capture full LLM response with tool calls for intermediate steps
if (event.data) {
const chatModelData = event.data;
const output = chatModelData.output;
// Check if this LLM response contains tool calls
if (output?.tool_calls && output.tool_calls.length > 0) {
// Collect tool calls for request building
// Note: For Gemini, we pass additional_kwargs to ALL tool calls
// so the signature can be applied to each when rebuilding
for (const toolCall of output.tool_calls) {
toolCalls.push({
tool: toolCall.name,
toolInput: toolCall.args,
toolCallId: toolCall.id || 'unknown',
type: toolCall.type || 'tool_call',
log:
output.content ||
`Calling ${toolCall.name} with input: ${JSON.stringify(toolCall.args)}`,
messageLog: [output],
// Pass additional_kwargs to ALL tool calls so signature is available
additionalKwargs: output.additional_kwargs as Record<string, unknown> | undefined,
});
}
}
}
break;
default:
break;
}
}
ctx.sendChunk('end', itemIndex);
// Include collected tool calls in the result
if (toolCalls.length > 0) {
agentResult.toolCalls = toolCalls;
}
return agentResult;
}
@@ -0,0 +1,193 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { EngineResponse, EngineRequest, IDataObject, ExecuteNodeResult } from 'n8n-workflow';
import type { RequestResponseMetadata } from './types';
/**
* HITL metadata type (extracted from RequestResponseMetadata for convenience)
*/
type HitlMetadata = NonNullable<RequestResponseMetadata['hitl']>;
/**
* Result of processing HITL responses
*/
export interface HitlProcessingResult {
/** If we need to execute gated tools, this contains the EngineRequest */
pendingGatedToolRequest?: EngineRequest<RequestResponseMetadata>;
/** Modified response with HITL approvals/denials properly formatted */
processedResponse: EngineResponse<RequestResponseMetadata>;
/** Whether any HITL tools were approved and need gated tool execution */
hasApprovedHitlTools: boolean;
}
/**
* Check if an action response is from an HITL tool
*/
function isHitlActionResponse(
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
): actionResponse is ExecuteNodeResult<RequestResponseMetadata> & {
action: { metadata: RequestResponseMetadata & { hitl: HitlMetadata } };
} {
const hitl = (actionResponse.action?.metadata as { hitl?: HitlMetadata } | undefined)?.hitl;
return hitl !== undefined;
}
/**
* Type guard to check if data contains an approval field
*/
function isApprovalData(data: unknown): data is { approved: boolean } {
return (
typeof data === 'object' &&
data !== null &&
'approved' in data &&
typeof (data as Record<string, unknown>).approved === 'boolean'
);
}
function getActionJsonResponse(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
return actionResponse.data?.data?.ai_tool?.[0]?.[0]?.json;
}
/**
* Extract approval status from HITL response data.
* SendAndWait webhook returns { approved: boolean } or { data: { approved: boolean } }
*/
function getApprovalStatus(
actionResponse: ExecuteNodeResult<RequestResponseMetadata>,
): boolean | undefined {
const json = getActionJsonResponse(actionResponse);
if (isApprovalData(json)) {
return json.approved;
}
const nestedData = (json as IDataObject | undefined)?.data;
if (isApprovalData(nestedData)) {
return nestedData.approved;
}
return undefined;
}
function getChatInput(actionResponse: ExecuteNodeResult<RequestResponseMetadata>) {
const json = getActionJsonResponse(actionResponse);
const chatInput = json?.chatInput ?? (json?.data as IDataObject | undefined)?.chatInput;
if (typeof chatInput === 'string') {
return chatInput;
}
return undefined;
}
function getDenialMessage(toolName: string, toolId: string, chatInput?: string): string {
const parts: string[] = [];
if (chatInput) {
parts.push(
`The user reviewed your planned tool call to ${toolName} (id: ${toolId}) and provided feedback: "${chatInput}".`,
);
} else {
parts.push(`User rejected the tool call to ${toolName} (id: ${toolId}).`);
parts.push('STOP what you are doing and wait for the user to tell you how to proceed.');
}
parts.push('The tool is still available if needed.');
return parts.join(' ');
}
/**
* Process HITL (Human-in-the-Loop) tool responses.
*
* When the Agent receives responses from HITL tools:
* 1. Check if the response indicates approval or denial
* 2. If approved: Generate EngineRequest for the gated tool
* 3. If denied: Modify response to indicate denial so Agent knows not to retry
*
* This enables the flow:
* Agent calls tool → HITL intercepts → sendAndWait → User approves →
* Agent generates new request for gated tool → Gated tool executes → Result to Agent
*/
export function processHitlResponses(
response: EngineResponse<RequestResponseMetadata> | undefined,
itemIndex: number,
): HitlProcessingResult {
if (!response || !response.actionResponses || response.actionResponses.length === 0) {
return {
processedResponse: response ?? { actionResponses: [], metadata: {} },
hasApprovedHitlTools: false,
};
}
const pendingGatedToolActions: EngineRequest<RequestResponseMetadata>['actions'] = [];
const processedActionResponses: Array<ExecuteNodeResult<RequestResponseMetadata>> = [];
let hasApprovedHitlTools = false;
for (const actionResponse of response.actionResponses) {
if (!isHitlActionResponse(actionResponse)) {
// Not an HITL tool, pass through unchanged
processedActionResponses.push(actionResponse);
continue;
}
const { hitl } = actionResponse.action.metadata;
const approved = getApprovalStatus(actionResponse);
const chatInput = getChatInput(actionResponse);
const toolName = hitl.gatedToolNodeName;
const toolId = actionResponse.action.id;
if (approved === true) {
hasApprovedHitlTools = true;
const input =
typeof hitl.originalInput === 'object'
? { tool: hitl.toolName, ...hitl.originalInput }
: { tool: hitl.toolName, input: hitl.originalInput };
pendingGatedToolActions.push({
actionType: 'ExecutionNodeAction' as const,
nodeName: hitl.gatedToolNodeName,
input,
type: NodeConnectionTypes.AiTool,
id: toolId,
metadata: {
itemIndex,
// Set the parent node to the HITL node for proper log tree structure
parentNodeName: actionResponse.action.nodeName,
},
});
} else {
const modifiedResponse: ExecuteNodeResult<RequestResponseMetadata> = {
...actionResponse,
data: {
...actionResponse.data,
data: {
ai_tool: [
[
{
json: {
output: getDenialMessage(toolName, toolId, chatInput),
},
},
],
],
},
},
};
processedActionResponses.push(modifiedResponse);
}
}
const result: HitlProcessingResult = {
processedResponse: {
...response,
actionResponses: processedActionResponses,
},
hasApprovedHitlTools,
};
if (pendingGatedToolActions.length > 0) {
result.pendingGatedToolRequest = {
actions: pendingGatedToolActions,
metadata: {
previousRequests: response.metadata?.previousRequests,
},
};
}
return result;
}
@@ -0,0 +1,47 @@
/**
* Converts intermediateSteps messageLog entries from LangChain class instances
* to plain objects. This ensures the data structure is consistent between
* runtime (expression evaluation) and UI display (after JSON serialization).
*
* Without this, AIMessage instances have properties like `content` and
* `tool_calls` directly, but their `toJSON()` wraps them under `kwargs`,
* causing expressions built from UI inspection to fail at runtime.
*/
export function serializeIntermediateSteps(
steps: Array<{ action: { messageLog?: unknown[] }; [key: string]: unknown }>,
): void {
for (const step of steps) {
if (step.action.messageLog) {
step.action.messageLog = step.action.messageLog.map((msg) => {
if (
msg &&
typeof msg === 'object' &&
typeof (msg as Record<string, unknown>).toJSON === 'function'
) {
return serializeMessage(msg);
}
return msg;
});
}
}
}
function serializeMessage(msg: unknown): Record<string, unknown> {
const m = msg as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const key of Object.keys(m)) {
if (key === 'toJSON' || key === '_getType') continue;
result[key] = m[key];
}
// Ensure type is included (may come from a getter on the prototype)
if (
!('type' in result) &&
typeof (m as Record<string, (...args: unknown[]) => unknown>)._getType === 'function'
) {
result.type = (m as Record<string, (...args: unknown[]) => unknown>)._getType();
}
return result;
}
@@ -0,0 +1,178 @@
import type { EngineResponse } from 'n8n-workflow';
import * as agentExecution from '../buildSteps';
import type { RequestResponseMetadata } from '../types';
import { buildResponseMetadata } from '../buildResponseMetadata';
// Mock the buildSteps function from agent-execution
jest.mock('../buildSteps', () => ({
buildSteps: jest.fn((response) => {
// Mock implementation: return previous requests if they exist
if (response?.actionResponses) {
return response.actionResponses.map((ar: any) => ({
action: {
tool: ar.action.nodeName,
toolInput: ar.action.input,
log: 'mock log',
toolCallId: ar.action.id,
type: 'tool_call',
},
observation: JSON.stringify(ar.data),
}));
}
return [];
}),
}));
describe('buildIterationMetadata', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should return metadata with iterationCount 1 when response is undefined', () => {
const result = buildResponseMetadata(undefined, 0);
expect(result).toEqual({
previousRequests: [],
itemIndex: 0,
iterationCount: 1,
});
});
it('should return metadata with iterationCount 1 when response has no metadata', () => {
const response = {
actionResponses: [],
} as unknown as EngineResponse<RequestResponseMetadata>;
const result = buildResponseMetadata(response, 0);
expect(result).toEqual({
previousRequests: [],
itemIndex: 0,
iterationCount: 1,
});
});
it('should return metadata with iterationCount 1 when response metadata has no iterationCount', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {},
};
const result = buildResponseMetadata(response, 0);
expect(result).toEqual({
previousRequests: [],
itemIndex: 0,
iterationCount: 1,
});
});
it('should increment iterationCount when response has existing iterationCount', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 3,
},
};
const result = buildResponseMetadata(response, 0);
expect(result).toEqual({
previousRequests: [],
itemIndex: 0,
iterationCount: 4,
});
});
it('should include previousRequests when response has actionResponses', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
{
action: {
id: 'call_123',
nodeName: 'TestTool',
input: { input: 'test data', id: 'call_123' },
metadata: { itemIndex: 0 },
actionType: 'ExecutionNodeAction',
type: 'ai_tool',
},
data: {
data: { ai_tool: [[{ json: { result: 'tool result' } }]] },
executionTime: 0,
startTime: 0,
executionIndex: 0,
source: [],
},
},
],
metadata: {
iterationCount: 1,
},
};
const result = buildResponseMetadata(response, 0);
expect(result.itemIndex).toBe(0);
expect(result.iterationCount).toBe(2);
expect(result.previousRequests).toHaveLength(1);
expect(result.previousRequests?.[0]).toMatchObject({
action: {
tool: 'TestTool',
toolCallId: 'call_123',
type: 'tool_call',
},
});
});
it('should handle multiple iterations correctly', () => {
// First iteration
const result1 = buildResponseMetadata(undefined, 0);
expect(result1.iterationCount).toBe(1);
// Second iteration
const response2: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: { iterationCount: 1 },
};
const result2 = buildResponseMetadata(response2, 0);
expect(result2.iterationCount).toBe(2);
// Third iteration
const response3: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: { iterationCount: 2 },
};
const result3 = buildResponseMetadata(response3, 0);
expect(result3.iterationCount).toBe(3);
});
it('should pass correct itemIndex to buildSteps', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: { iterationCount: 1 },
};
buildResponseMetadata(response, 5);
expect(agentExecution.buildSteps).toHaveBeenCalledWith(response, 5);
});
it('should handle iterationCount starting from 0', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {
iterationCount: 0,
},
};
const result = buildResponseMetadata(response, 0);
expect(result).toEqual({
previousRequests: [],
itemIndex: 0,
iterationCount: 1,
});
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,654 @@
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import {
HumanMessage,
AIMessage,
SystemMessage,
ToolMessage,
trimMessages,
} from '@langchain/core/messages';
import { mock } from 'jest-mock-extended';
import type { BaseChatMemory } from '@langchain/classic/memory';
import {
loadMemory,
saveToMemory,
buildToolContext,
extractToolCallId,
buildMessagesFromSteps,
} from '../memoryManagement';
import type { ToolCallData } from '../types';
jest.mock('@langchain/core/messages', () => ({
...jest.requireActual('@langchain/core/messages'),
trimMessages: jest.fn(),
}));
describe('memoryManagement', () => {
let mockMemory: jest.Mocked<BaseChatMemory>;
let mockModel: jest.Mocked<BaseChatModel>;
beforeEach(() => {
jest.clearAllMocks();
mockMemory = mock<BaseChatMemory>();
mockModel = mock<BaseChatModel>();
});
describe('loadMemory', () => {
it('should return undefined when no memory is provided', async () => {
const result = await loadMemory(undefined);
expect(result).toBeUndefined();
});
it('should load chat history from memory', async () => {
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toEqual(chatHistory);
expect(mockMemory.loadMemoryVariables).toHaveBeenCalledWith({});
});
it('should return empty array when chat_history is not present', async () => {
mockMemory.loadMemoryVariables.mockResolvedValue({});
const result = await loadMemory(mockMemory);
expect(result).toEqual([]);
});
it('should remove orphaned ToolMessage at start of chat history', async () => {
// Simulates memory trimming that removed the AIMessage but left the ToolMessage
const chatHistory = [
new ToolMessage({ content: 'Result', tool_call_id: 'orphaned-id', name: 'tool' }),
new HumanMessage('Next question'),
new AIMessage('Answer'),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(2);
expect(result?.[0]).toBeInstanceOf(HumanMessage);
expect(result?.[1]).toBeInstanceOf(AIMessage);
});
it('should remove orphaned AIMessage with tool_calls at start', async () => {
// Simulates memory trimming that kept AIMessage with tool_calls but removed the ToolMessage
const orphanedAI = new AIMessage({
content: 'Calling tool',
tool_calls: [{ id: 'call-123', name: 'tool', args: {}, type: 'tool_call' }],
});
const chatHistory = [orphanedAI, new HumanMessage('Next question'), new AIMessage('Answer')];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(2);
expect(result?.[0]).toBeInstanceOf(HumanMessage);
expect(result?.[1]).toBeInstanceOf(AIMessage);
});
it('should remove multiple consecutive orphaned ToolMessages at start', async () => {
const chatHistory = [
new ToolMessage({ content: 'Result 1', tool_call_id: 'id-1', name: 'tool1' }),
new ToolMessage({ content: 'Result 2', tool_call_id: 'id-2', name: 'tool2' }),
new ToolMessage({ content: 'Result 3', tool_call_id: 'id-3', name: 'tool3' }),
new HumanMessage('Next question'),
new AIMessage('Answer'),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(2);
expect(result?.[0]).toBeInstanceOf(HumanMessage);
expect(result?.[1]).toBeInstanceOf(AIMessage);
});
it('should remove chain of ToolMessage -> AIMessage(tool_calls) at start via recursive cleanup', async () => {
// After removing the first ToolMessage, an orphaned AIMessage with tool_calls is revealed
// (not followed by a ToolMessage), requiring another cleanup pass
const chatHistory = [
new ToolMessage({ content: 'Orphan result', tool_call_id: 'id-1', name: 'tool1' }),
new AIMessage({
content: 'Calling another tool',
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
}),
new HumanMessage('Question'),
new AIMessage('Answer'),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(2);
expect(result?.[0]).toBeInstanceOf(HumanMessage);
expect(result?.[1]).toBeInstanceOf(AIMessage);
});
it('should handle orphaned AIMessage(tool_calls) followed by more orphaned ToolMessages', async () => {
const chatHistory = [
new AIMessage({
content: 'Calling tool',
tool_calls: [{ id: 'call-1', name: 'tool1', args: {}, type: 'tool_call' as const }],
}),
// This AIMessage has tool_calls but no following ToolMessage (next is HumanMessage)
new AIMessage({
content: 'Another call',
tool_calls: [{ id: 'call-2', name: 'tool2', args: {}, type: 'tool_call' as const }],
}),
new HumanMessage('Question'),
new AIMessage('Answer'),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(2);
expect(result?.[0]).toBeInstanceOf(HumanMessage);
expect(result?.[1]).toBeInstanceOf(AIMessage);
});
it('should return empty array when all messages are orphans', async () => {
const chatHistory = [
new ToolMessage({ content: 'Result', tool_call_id: 'id-1', name: 'tool' }),
new AIMessage({
content: 'Call',
tool_calls: [{ id: 'call-1', name: 'tool', args: {}, type: 'tool_call' as const }],
}),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory);
expect(result).toHaveLength(0);
});
it('should trim messages when maxTokens is provided', async () => {
const chatHistory = [
new SystemMessage('System prompt'),
new HumanMessage('Hello'),
new AIMessage('Hi there!'),
new HumanMessage('How are you?'),
new AIMessage('I am doing well!'),
];
const trimmedHistory = [
new SystemMessage('System prompt'),
new HumanMessage('How are you?'),
new AIMessage('I am doing well!'),
];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
(trimMessages as jest.Mock).mockResolvedValue(trimmedHistory);
const result = await loadMemory(mockMemory, mockModel, 2000);
expect(result).toEqual(trimmedHistory);
expect(trimMessages).toHaveBeenCalledWith(chatHistory, {
strategy: 'last',
maxTokens: 2000,
tokenCounter: mockModel,
includeSystem: true,
startOn: 'human',
allowPartial: true,
});
});
it('should not trim messages when maxTokens is not provided', async () => {
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory, mockModel);
expect(result).toEqual(chatHistory);
expect(trimMessages).not.toHaveBeenCalled();
});
it('should not trim messages when model is not provided', async () => {
const chatHistory = [new HumanMessage('Hello'), new AIMessage('Hi there!')];
mockMemory.loadMemoryVariables.mockResolvedValue({ chat_history: chatHistory });
const result = await loadMemory(mockMemory, undefined, 2000);
expect(result).toEqual(chatHistory);
expect(trimMessages).not.toHaveBeenCalled();
});
});
describe('saveToMemory', () => {
it('should save conversation to memory', async () => {
const input = 'What is 2+2?';
const output = 'The answer is 4';
await saveToMemory(input, output, mockMemory);
expect(mockMemory.saveContext).toHaveBeenCalledWith({ input }, { output });
});
it('should not save when output is empty', async () => {
const input = 'What is 2+2?';
const output = '';
await saveToMemory(input, output, mockMemory);
expect(mockMemory.saveContext).not.toHaveBeenCalled();
});
it('should not save when memory is not provided', async () => {
const input = 'What is 2+2?';
const output = 'The answer is 4';
await saveToMemory(input, output, undefined);
// Should not throw error
expect(mockMemory.saveContext).not.toHaveBeenCalled();
});
it('should not save when both output and memory are missing', async () => {
const input = 'What is 2+2?';
await saveToMemory(input, '', undefined);
expect(mockMemory.saveContext).not.toHaveBeenCalled();
});
});
describe('extractToolCallId', () => {
beforeEach(() => {
// Mock Date.now() to return consistent values for synthetic IDs
jest.spyOn(Date, 'now').mockReturnValue(1234567890);
jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should extract string ID directly', () => {
const result = extractToolCallId('call-123', 'calculator');
expect(result).toBe('call-123');
});
it('should extract ID from object with id property', () => {
const result = extractToolCallId({ id: 'call-456' }, 'search');
expect(result).toBe('call-456');
});
it('should extract ID from array', () => {
const result = extractToolCallId(['call-789'], 'weather');
expect(result).toBe('call-789');
});
it('should recursively extract from nested array', () => {
const result = extractToolCallId([['call-nested']], 'tool');
expect(result).toBe('call-nested');
});
it('should extract from array of objects', () => {
const result = extractToolCallId([{ id: 'call-array-obj' }], 'tool');
expect(result).toBe('call-array-obj');
});
it('should generate synthetic ID for null', () => {
const result = extractToolCallId(null, 'unknown');
expect(result).toBe('synthetic_unknown_1234567890');
});
it('should generate synthetic ID for undefined', () => {
const result = extractToolCallId(undefined, 'test');
expect(result).toBe('synthetic_test_1234567890');
});
it('should generate synthetic ID for empty string', () => {
const result = extractToolCallId('', 'tool');
expect(result).toBe('synthetic_tool_1234567890');
});
it('should generate synthetic ID for object without id property', () => {
const result = extractToolCallId({ other: 'value' }, 'tool');
expect(result).toBe('synthetic_tool_1234567890');
});
it('should generate synthetic ID for empty array', () => {
const result = extractToolCallId([], 'tool');
expect(result).toBe('synthetic_tool_1234567890');
});
});
describe('buildMessagesFromSteps', () => {
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should build messages with proper AIMessage from messageLog', () => {
const aiMessage = new AIMessage({
content: 'Let me calculate that',
tool_calls: [
{
id: 'call-123',
name: 'calculator',
args: { expression: '2+2' },
type: 'tool_call',
},
],
});
const steps: ToolCallData[] = [
{
action: {
tool: 'calculator',
toolInput: { expression: '2+2' },
log: 'Using calculator',
messageLog: [aiMessage],
toolCallId: 'call-123',
type: 'tool_call',
},
observation: '4',
},
];
const result = buildMessagesFromSteps(steps);
expect(result).toHaveLength(2);
expect(result[0]).toBe(aiMessage);
expect(result[1]).toBeInstanceOf(ToolMessage);
expect(result[1].content).toBe('4');
expect((result[1] as ToolMessage).tool_call_id).toBe('call-123');
expect((result[1] as ToolMessage).name).toBe('calculator');
});
it('should create synthetic AIMessage when messageLog is missing', () => {
const steps: ToolCallData[] = [
{
action: {
tool: 'search',
toolInput: { query: 'test' },
log: 'Searching',
toolCallId: 'call-456',
type: 'tool_call',
},
observation: 'Found results',
},
];
const result = buildMessagesFromSteps(steps);
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(AIMessage);
expect(result[0].content).toContain('search');
expect(result[0].content).toContain('test');
expect((result[0] as AIMessage).tool_calls).toHaveLength(1);
expect((result[0] as AIMessage).tool_calls?.[0].id).toBe('call-456');
});
it('should handle multiple tool calls in sequence', () => {
const aiMessage1 = new AIMessage({
content: 'Checking weather',
tool_calls: [
{ id: 'call-1', name: 'weather', args: { location: 'NYC' }, type: 'tool_call' },
],
});
const aiMessage2 = new AIMessage({
content: 'Getting time',
tool_calls: [{ id: 'call-2', name: 'time', args: { timezone: 'EST' }, type: 'tool_call' }],
});
const steps: ToolCallData[] = [
{
action: {
tool: 'weather',
toolInput: { location: 'NYC' },
log: 'Weather',
messageLog: [aiMessage1],
toolCallId: 'call-1',
type: 'tool_call',
},
observation: 'Sunny, 72°F',
},
{
action: {
tool: 'time',
toolInput: { timezone: 'EST' },
log: 'Time',
messageLog: [aiMessage2],
toolCallId: 'call-2',
type: 'tool_call',
},
observation: '14:30',
},
];
const result = buildMessagesFromSteps(steps);
expect(result).toHaveLength(4);
expect(result[0]).toBe(aiMessage1);
expect(result[1]).toBeInstanceOf(ToolMessage);
expect(result[2]).toBe(aiMessage2);
expect(result[3]).toBeInstanceOf(ToolMessage);
});
it('should return empty array for empty steps', () => {
const result = buildMessagesFromSteps([]);
expect(result).toHaveLength(0);
});
});
describe('saveToMemory with steps (message-based storage)', () => {
let mockChatHistory: any;
beforeEach(() => {
jest.spyOn(console, 'log').mockImplementation();
mockChatHistory = {
addMessages: jest.fn().mockResolvedValue(undefined),
};
mockMemory.chatHistory = mockChatHistory;
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should use message-based storage when steps are provided and addMessages is available', async () => {
const aiMessage = new AIMessage({
content: 'Let me calculate',
tool_calls: [
{ id: 'call-123', name: 'calculator', args: { expression: '2+2' }, type: 'tool_call' },
],
});
const steps: ToolCallData[] = [
{
action: {
tool: 'calculator',
toolInput: { expression: '2+2' },
log: 'Calc',
messageLog: [aiMessage],
toolCallId: 'call-123',
type: 'tool_call',
},
observation: '4',
},
];
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
expect(mockChatHistory.addMessages).toHaveBeenCalledTimes(1);
const savedMessages = mockChatHistory.addMessages.mock.calls[0][0];
expect(savedMessages).toHaveLength(4);
expect(savedMessages[0]).toBeInstanceOf(HumanMessage);
expect(savedMessages[0].content).toBe('Calculate 2+2');
expect(savedMessages[1]).toBe(aiMessage);
expect(savedMessages[2]).toBeInstanceOf(ToolMessage);
expect(savedMessages[3]).toBeInstanceOf(AIMessage);
expect(savedMessages[3].content).toBe('The answer is 4');
});
it('should fall back to string format when addMessages is not available', async () => {
// Create a chat history object without addMessages method
mockMemory.chatHistory = {} as any;
const steps: ToolCallData[] = [
{
action: {
tool: 'calculator',
toolInput: { expression: '2+2' },
log: 'Calc',
toolCallId: 'call-123',
type: 'tool_call',
},
observation: '4',
},
];
await saveToMemory('Calculate 2+2', 'The answer is 4', mockMemory, steps);
expect(mockMemory.saveContext).toHaveBeenCalledWith(
{ input: 'Calculate 2+2' },
{
output:
'[Used tools: Tool: calculator, Input: {"expression":"2+2"}, Result: 4] The answer is 4',
},
);
});
it('should use saveContext when steps array is empty', async () => {
await saveToMemory('Simple question', 'Simple answer', mockMemory, []);
expect(mockMemory.saveContext).toHaveBeenCalledWith(
{ input: 'Simple question' },
{ output: 'Simple answer' },
);
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
});
it('should use saveContext when steps is undefined', async () => {
await saveToMemory('Simple question', 'Simple answer', mockMemory);
expect(mockMemory.saveContext).toHaveBeenCalledWith(
{ input: 'Simple question' },
{ output: 'Simple answer' },
);
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
});
it('should use saveContext when all steps are from previous turns', async () => {
const aiMessage = new AIMessage({
content: 'Using tool',
tool_calls: [{ id: 'call-123', name: 'calculator', args: {}, type: 'tool_call' }],
});
const steps: ToolCallData[] = [
{
action: {
tool: 'calculator',
toolInput: { expression: '2+2' },
log: 'Calc',
messageLog: [aiMessage],
toolCallId: 'call-123',
type: 'tool_call',
},
observation: '4',
},
];
// All steps are from previous turns (previousStepsCount = 1)
await saveToMemory('New question', 'New answer', mockMemory, steps, 1);
expect(mockMemory.saveContext).toHaveBeenCalledWith(
{ input: 'New question' },
{ output: 'New answer' },
);
expect(mockChatHistory.addMessages).not.toHaveBeenCalled();
});
});
describe('buildToolContext', () => {
it('should build tool context string from single step', () => {
const steps: ToolCallData[] = [
{
action: {
tool: 'calculator',
toolInput: { expression: '2+2' },
log: 'Using calculator',
toolCallId: 'call_123',
type: 'tool_call',
},
observation: '4',
},
];
const result = buildToolContext(steps);
expect(result).toBe('Tool: calculator, Input: {"expression":"2+2"}, Result: 4');
});
it('should build tool context string from multiple steps', () => {
const steps: ToolCallData[] = [
{
action: {
tool: 'weather',
toolInput: { location: 'New York' },
log: 'Getting weather',
toolCallId: 'call_123',
type: 'tool_call',
},
observation: 'Sunny, 72°F',
},
{
action: {
tool: 'time',
toolInput: { timezone: 'EST' },
log: 'Getting time',
toolCallId: 'call_124',
type: 'tool_call',
},
observation: '14:30',
},
];
const result = buildToolContext(steps);
expect(result).toBe(
'Tool: weather, Input: {"location":"New York"}, Result: Sunny, 72°F; Tool: time, Input: {"timezone":"EST"}, Result: 14:30',
);
});
it('should return empty string for empty steps array', () => {
const result = buildToolContext([]);
expect(result).toBe('');
});
it('should handle complex tool inputs', () => {
const steps: ToolCallData[] = [
{
action: {
tool: 'search',
toolInput: {
query: 'typescript testing',
filters: { language: 'en', date: '2024' },
limit: 10,
},
log: 'Searching',
toolCallId: 'call_125',
type: 'tool_call',
},
observation: 'Found 10 results',
},
];
const result = buildToolContext(steps);
expect(result).toBe(
'Tool: search, Input: {"query":"typescript testing","filters":{"language":"en","date":"2024"},"limit":10}, Result: Found 10 results',
);
});
});
});
@@ -0,0 +1,224 @@
import { NodeConnectionTypes } from 'n8n-workflow';
import type { EngineResponse, ExecuteNodeResult, IDataObject, ITaskData } from 'n8n-workflow';
import { processHitlResponses } from '../processHitlResponses';
import type { HitlMetadata, RequestResponseMetadata } from '../types';
const createMockTaskData = (json: IDataObject): ITaskData => ({
executionTime: 0,
startTime: Date.now(),
executionIndex: 0,
source: [],
data: {
ai_tool: [[{ json }]],
},
});
const createHitlActionResponse = (
approved: boolean,
hitlMetadata: HitlMetadata,
actionId = 'action-1',
chatInput?: string,
): ExecuteNodeResult<RequestResponseMetadata> => ({
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'HITL Node',
input: {},
type: NodeConnectionTypes.AiTool,
id: actionId,
metadata: { hitl: hitlMetadata },
},
data: createMockTaskData({ approved, chatInput }),
});
const createNonHitlActionResponse = (
actionId = 'action-2',
): ExecuteNodeResult<RequestResponseMetadata> => ({
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'Regular Tool',
input: {},
type: NodeConnectionTypes.AiTool,
id: actionId,
metadata: {},
},
data: createMockTaskData({ result: 'success' }),
});
describe('processHitlResponses', () => {
const hitlMetadata = {
gatedToolNodeName: 'Gated Tool Node',
toolName: 'my_tool',
originalInput: { query: 'test' },
};
describe('empty/undefined responses', () => {
it('returns empty result for undefined response', () => {
const result = processHitlResponses(undefined, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toEqual([]);
});
it('returns empty result for response with no action responses', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
});
});
describe('non-HITL responses', () => {
it('passes through unchanged', () => {
const actionResponse = createNonHitlActionResponse();
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [actionResponse],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.processedResponse.actionResponses).toHaveLength(1);
expect(result.processedResponse.actionResponses[0]).toEqual(actionResponse);
});
});
describe('approved HITL responses', () => {
it('creates pending gated tool request', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest).toBeDefined();
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
const action = result.pendingGatedToolRequest!.actions[0];
expect(action.nodeName).toBe('Gated Tool Node');
expect(action.input).toEqual({ query: 'test', tool: 'my_tool' });
expect(action.id).toBe('action-1');
expect(action.metadata?.parentNodeName).toBe('HITL Node');
});
it('removes approved HITL response from processed responses', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(true, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.processedResponse.actionResponses).toHaveLength(0);
});
it('handles nested approval data format', () => {
const actionResponse: ExecuteNodeResult<RequestResponseMetadata> = {
action: {
actionType: 'ExecutionNodeAction',
nodeName: 'HITL Node',
input: {},
type: NodeConnectionTypes.AiTool,
id: 'action-1',
metadata: { hitl: hitlMetadata },
},
data: createMockTaskData({ data: { approved: true } }),
};
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [actionResponse],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
});
});
describe('denied HITL responses', () => {
it('modifies response with denial message', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(false, hitlMetadata)],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toHaveLength(1);
const processedData = result.processedResponse.actionResponses[0].data?.data
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
expect(processedData.output).toMatch(/reject/i);
});
it('modifies response with denial message and chat input', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [createHitlActionResponse(false, hitlMetadata, 'action-1', 'chat input')],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(false);
expect(result.pendingGatedToolRequest).toBeUndefined();
expect(result.processedResponse.actionResponses).toHaveLength(1);
const processedData = result.processedResponse.actionResponses[0].data?.data
?.ai_tool?.[0]?.[0]?.json as Record<string, unknown>;
expect(processedData.output).toMatch(/chat input/i);
});
});
describe('mixed responses', () => {
it('processes HITL and non-HITL responses correctly', () => {
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
createNonHitlActionResponse('regular-1'),
createHitlActionResponse(true, hitlMetadata, 'hitl-approved'),
createHitlActionResponse(
false,
{ ...hitlMetadata, toolName: 'denied_tool' },
'hitl-denied',
),
createNonHitlActionResponse('regular-2'),
],
metadata: {},
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest?.actions).toHaveLength(1);
// 2 non-HITL + 1 denied HITL = 3 (approved HITL is removed)
expect(result.processedResponse.actionResponses).toHaveLength(3);
});
});
describe('multiple approvals', () => {
it('batches multiple gated tool actions', () => {
const hitlMetadata2 = { ...hitlMetadata, gatedToolNodeName: 'Another Gated Tool' };
const response: EngineResponse<RequestResponseMetadata> = {
actionResponses: [
createHitlActionResponse(true, hitlMetadata, 'hitl-1'),
createHitlActionResponse(true, hitlMetadata2, 'hitl-2'),
],
metadata: { previousRequests: [{ action: {} as never, observation: 'prev' }] },
};
const result = processHitlResponses(response, 0);
expect(result.hasApprovedHitlTools).toBe(true);
expect(result.pendingGatedToolRequest?.actions).toHaveLength(2);
expect(result.pendingGatedToolRequest?.metadata?.previousRequests).toHaveLength(1);
});
});
});
@@ -0,0 +1,165 @@
import { serializeIntermediateSteps } from '../serializeIntermediateSteps';
describe('serializeIntermediateSteps', () => {
it('should convert class instances with toJSON to plain objects', () => {
const fakeAIMessage = {
content: 'I need to call a tool',
tool_calls: [{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' }],
additional_kwargs: {},
response_metadata: { model: 'gpt-4' },
id: 'msg_abc',
name: undefined,
toJSON() {
return {
lc: 1,
type: 'constructor',
id: ['langchain_core', 'messages', 'AIMessage'],
kwargs: {
content: this.content,
tool_calls: this.tool_calls,
additional_kwargs: this.additional_kwargs,
},
};
},
};
const steps = [
{
action: {
tool: 'TestTool',
toolInput: { input: 'test' },
log: 'Calling TestTool',
messageLog: [fakeAIMessage],
toolCallId: 'call_123',
type: 'function',
},
observation: 'Tool result',
},
];
serializeIntermediateSteps(steps);
const serializedMsg = steps[0].action.messageLog[0] as Record<string, unknown>;
// Should be a plain object, not the original class instance
expect(serializedMsg).not.toBe(fakeAIMessage);
expect(typeof serializedMsg.toJSON).toBe('undefined');
// Direct property access should work
expect(serializedMsg.content).toBe('I need to call a tool');
expect(serializedMsg.tool_calls).toEqual([
{ name: 'TestTool', args: { input: 'test' }, id: 'call_123' },
]);
expect(serializedMsg.additional_kwargs).toEqual({});
expect(serializedMsg.response_metadata).toEqual({ model: 'gpt-4' });
expect(serializedMsg.id).toBe('msg_abc');
});
it('should leave plain objects unchanged', () => {
const plainMsg = {
content: 'Hello',
tool_calls: [],
};
const steps = [
{
action: {
tool: 'TestTool',
toolInput: {},
log: '',
messageLog: [plainMsg],
toolCallId: 'call_1',
type: 'function',
},
},
];
serializeIntermediateSteps(steps);
// Should be the same reference since it has no toJSON
expect(steps[0].action.messageLog[0]).toBe(plainMsg);
});
it('should handle steps without messageLog', () => {
const steps = [
{
action: {
tool: 'TestTool',
toolInput: {},
log: '',
toolCallId: 'call_1',
type: 'function',
},
},
];
// Should not throw
expect(() =>
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>),
).not.toThrow();
});
it('should handle empty steps array', () => {
const steps: Array<{ action: { messageLog?: unknown[] } }> = [];
expect(() => serializeIntermediateSteps(steps)).not.toThrow();
});
it('should include type from _getType when not an own property', () => {
const proto = {
_getType() {
return 'ai';
},
};
const fakeMsg = Object.create(proto) as Record<string, unknown>;
fakeMsg.content = 'test';
fakeMsg.toJSON = () => ({ lc: 1, type: 'constructor', kwargs: {} });
const steps = [
{
action: {
messageLog: [fakeMsg],
},
},
];
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
const serialized = steps[0].action.messageLog[0] as Record<string, unknown>;
expect(serialized.type).toBe('ai');
expect(serialized.content).toBe('test');
});
it('should handle mixed messageLog entries', () => {
const classInstance = {
content: 'from class',
toJSON() {
return { kwargs: { content: this.content } };
},
};
const plainObject = { content: 'from plain' };
const primitiveValue = 'just a string';
const steps = [
{
action: {
messageLog: [classInstance, plainObject, primitiveValue],
},
},
];
serializeIntermediateSteps(steps as Array<{ action: { messageLog?: unknown[] } }>);
const [serializedClass, unchangedPlain, unchangedPrimitive] = steps[0].action.messageLog;
// Class instance should be serialized
expect(serializedClass).not.toBe(classInstance);
expect((serializedClass as Record<string, unknown>).content).toBe('from class');
expect(typeof (serializedClass as Record<string, unknown>).toJSON).toBe('undefined');
// Plain object should be unchanged
expect(unchangedPlain).toBe(plainObject);
// Primitive should be unchanged
expect(unchangedPrimitive).toBe(primitiveValue);
});
});
@@ -0,0 +1,214 @@
import type { AIMessage } from '@langchain/core/messages';
import type { IDataObject, GenericValue } from 'n8n-workflow';
import type { ZodType } from 'zod';
/**
* Represents a tool call request from an LLM.
* This is a generic format that can be used across different agent types.
*/
export type ToolCallRequest = {
/** The name of the tool to call */
tool: string;
/** The input arguments for the tool */
toolInput: Record<string, unknown>;
/** Unique identifier for this tool call */
toolCallId: string;
/** Type of the tool call (e.g., 'tool_call', 'function') */
type?: string;
/** Log message or description */
log?: string;
/** Full message log including LLM response */
messageLog?: unknown[];
/** Additional kwargs from the LLM response (for Gemini thought signatures) */
additionalKwargs?: Record<string, unknown>;
};
/**
* Represents a tool call action and its observation result.
* Used for building agent steps and maintaining conversation context.
*/
export type ToolCallData = {
action: {
tool: string;
toolInput: Record<string, unknown>;
log: string | number | true | object;
messageLog?: AIMessage[];
toolCallId: IDataObject | GenericValue | GenericValue[] | IDataObject[];
type: string | number | true | object;
};
observation: string;
};
/**
* Result from an agent execution, optionally including tool calls and intermediate steps.
*/
export type AgentResult = {
/** The final output from the agent */
output: string;
/** Tool calls that need to be executed */
toolCalls?: ToolCallRequest[];
/** Intermediate steps showing the agent's reasoning */
intermediateSteps?: ToolCallData[];
};
/**
* Anthropic thinking content block
*/
export type ThinkingContentBlock = {
type: 'thinking';
thinking: string;
signature: string;
};
/**
* Anthropic redacted thinking content block
*/
export type RedactedThinkingContentBlock = {
type: 'redacted_thinking';
data: string;
};
/**
* Anthropic tool use content block
*/
export type ToolUseContentBlock = {
type: 'tool_use';
id: string;
name: string;
input: Record<string, unknown>;
};
/**
* Gemini thought signature content block
*/
export type GeminiThoughtSignatureBlock = {
thoughtSignature: string;
};
/**
* Union type for all supported content blocks
*/
export type ContentBlock =
| ThinkingContentBlock
| RedactedThinkingContentBlock
| ToolUseContentBlock
| GeminiThoughtSignatureBlock;
/**
* Google/Gemini-specific thinking metadata.
*/
export type GoogleThinkingMetadata = {
/** Thought signature for Gemini extended thinking */
thoughtSignature?: string;
};
/**
* Anthropic-specific thinking metadata.
*/
export type AnthropicThinkingMetadata = {
/** Thinking content from extended thinking mode */
thinkingContent?: string;
/** Type of thinking block (thinking or redacted_thinking) */
thinkingType?: 'thinking' | 'redacted_thinking';
/** Cryptographic signature for thinking blocks */
thinkingSignature?: string;
};
/**
* HITL (Human-in-the-Loop) metadata - presence indicates this is an HITL tool action.
*/
export type HitlMetadata = {
/** The gated tool node name that will be executed after approval */
gatedToolNodeName: string;
/** The tool name as seen by the LLM */
toolName: string;
/** Original input for the gated tool */
originalInput: IDataObject;
};
/**
* Thinking metadata extracted from LLM responses (Anthropic/Google extended thinking).
*/
export type ThinkingMetadata = {
google?: GoogleThinkingMetadata;
anthropic?: AnthropicThinkingMetadata;
};
/**
* Metadata for engine requests and responses.
*/
export type RequestResponseMetadata = {
/** Item index being processed */
itemIndex?: number;
/** Custom parent node name for log tree structure (overrides default parent) */
parentNodeName?: string;
/** Previous tool call requests (for multi-turn conversations) */
previousRequests?: ToolCallData[];
/** Current iteration count (for max iterations enforcement) */
iterationCount?: number;
/** Google/Gemini-specific metadata */
google?: GoogleThinkingMetadata;
/** Anthropic-specific metadata */
anthropic?: AnthropicThinkingMetadata;
/** HITL (Human-in-the-Loop) metadata - presence indicates this is an HITL tool action */
hitl?: HitlMetadata;
};
/**
* Metadata attached to LangChain tools for tracking source nodes and HITL gating.
* Extends Record<string, unknown> for compatibility with LangChain's Tool.metadata type.
*/
export interface ToolMetadata extends Record<string, unknown> {
/** The n8n node name that provides this tool */
sourceNodeName?: string;
/** For HITL tools, the gated tool node that will be executed after approval */
gatedToolNodeName?: string;
/** The original schema of the tool */
originalSchema?: ZodType;
/** Whether this tool came from a toolkit (vs. a standalone tool node) */
isFromToolkit?: boolean;
}
/**
* Type guard to check if a block is a thinking content block
*/
export function isThinkingBlock(block: unknown): block is ThinkingContentBlock {
return (
typeof block === 'object' &&
block !== null &&
'type' in block &&
block.type === 'thinking' &&
'thinking' in block &&
typeof block.thinking === 'string' &&
'signature' in block &&
typeof block.signature === 'string'
);
}
/**
* Type guard to check if a block is a redacted thinking content block
*/
export function isRedactedThinkingBlock(block: unknown): block is RedactedThinkingContentBlock {
return (
typeof block === 'object' &&
block !== null &&
'type' in block &&
block.type === 'redacted_thinking' &&
'data' in block &&
typeof block.data === 'string'
);
}
/**
* Type guard to check if a block is a Gemini thought signature block
*/
export function isGeminiThoughtSignatureBlock(
block: unknown,
): block is GeminiThoughtSignatureBlock {
return (
typeof block === 'object' &&
block !== null &&
'thoughtSignature' in block &&
typeof block.thoughtSignature === 'string'
);
}