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 { 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 { 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); }