Post

Inside ConversationMemoryFactory: How NeuroLink Picks and Wires a Memory Backend

Inside ConversationMemoryFactory: How NeuroLink Picks and Wires a Memory Backend — companion deep-dive for the NeuroLink blog with architectural detail and code examples.

Inside ConversationMemoryFactory: How NeuroLink Picks and Wires a Memory Backend

We designed NeuroLink’s conversation memory factory because our developers were wasting time managing two different code paths for state. Local development runs fastest with a zero-dependency, in-memory session store. But our production AI agents, running on a distributed cluster, required a persistent, shared Redis backend to provide a continuous user experience. Writing application code that forks on process.env.NODE_ENV is a direct path to brittle, untestable systems. We needed a single, clean interface for conversation history and a factory to select the right backend at startup, not a dozen if/else blocks scattered across the agent and tool-use logic.

The core problem is separating the what from the how. An AI agent needs to store a turn, retrieve context for the next API call, and maybe clear a session. It should not need to know if that session lives in a Map object on the local process or in a Redis cluster an ocean away. The storeConversationTurn method should have a single, predictable signature regardless of the underlying storage mechanism. This post dives into the source of how NeuroLink’s createConversationMemoryManager function makes that choice, how the IConversationMemoryManager interface enforces the contract, and how the Redis implementation solves subtle race conditions that the in-memory version never sees. For a higher-level overview of memory strategies, our previous post on Conversation Memory: Building Stateful AI Applications is a good starting point.

The Interface Contract: IConversationMemoryManager

Everything starts with the contract. The IConversationMemoryManager type defines the surface area that all memory backends must expose. It guarantees that any component asking for a memory manager gets an object with a predictable, consistent set of methods. There is no “if redis then do this” logic in the consuming code. This abstraction is the key to decoupling our application logic from our infrastructure.

The interface, defined in src/lib/types/conversationMemoryInterface.ts, focuses on core responsibilities:

  • initialize: A method to prepare the manager, which for the RedisConversationMemoryManager involves calling checkRedisAvailability to ensure the backend is reachable before accepting requests. The in-memory ConversationMemoryManager has an empty implementation.
  • storeConversationTurn: The primary write method. It takes the session ID and the latest turn (user message, assistant reply) and appends it to the session’s history. This is also the point where summarization is triggered.
  • getSession: Retrieves the entire session object, including messages and metadata. It returns undefined if a session does not exist.
  • buildContextMessages: The most critical method for the agent. It takes a session ID and constructs the precise list of ChatMessage objects to be sent to the LLM, including any summaries and respecting token limits.
  • clearSession: Deletes a single session. This is used for user-requested data deletion or automated cleanup. It returns a boolean indicating if a session was found and deleted.
  • clearAllSessions: A more powerful admin-level function to wipe the entire store. In Redis, this executes a FLUSHDB command and requires extreme caution.
  • getStats: Provides observability into the memory store, returning counts of active sessions and total messages. The Redis version gets this info from getPoolStats.
  • getSessionMessages and setSessionMessages: Low-level “escape hatch” methods for directly reading or overwriting the message history of a session, used for complex migration or repair scripts.
  • close: An optional method to gracefully shut down connections. This is vital for the RedisConversationMemoryManager to call releasePooledRedisClient and terminate the connection pool without leaking resources.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
export type IConversationMemoryManager = {
  initialize(): Promise<void> | void;

  storeConversationTurn(options: StoreConversationTurnOptions): Promise<void>;

  getSession(
    sessionId: string,
    userId?: string
  ): Promise<SessionMemory | undefined> | SessionMemory | undefined;

  buildContextMessages(
    options: BuildContextMessagesOptions
  ): Promise<ChatMessage[]>;

  clearSession(sessionId: string, userId?: string): Promise<boolean> | boolean;

  clearAllSessions(): Promise<void> | void;

  getStats(): Promise<ConversationMemoryStats> | ConversationMemoryStats;

  getSessionMessages(
    sessionId: string,
    userId?: string
  ): Promise<ChatMessage[] | undefined> | ChatMessage[] | undefined;

  setSessionMessages(
    sessionId: string,
    messages: ChatMessage[],
    userId?: string
  ): Promise<void> | void;

  close?(): Promise<void>;
};

This contract ensures that whether we’re using the simple ConversationMemoryManager or the production-grade RedisConversationMemoryManager, the calling code never changes. The responsibility for persistence is entirely encapsulated.

The Factory Predicate: getStorageType and getRedisConfigFromEnv

The choice of which implementation to instantiate happens once, at application startup, inside the factory module. The module exports a createConversationMemoryManager function that acts as the public entry point. Internally, it relies on two helper functions to decide what to do.

First, getStorageType reads the STORAGE_TYPE environment variable. It normalizes the value to either "memory" or "redis" and defaults to "memory" if the variable is missing or invalid. This provides a simple, universal switch.

1
2
3
4
5
6
7
export function getStorageType(): StorageType {
  const storageType = process.env.STORAGE_TYPE?.toLowerCase();
  if (storageType === 'redis') {
    return 'redis';
  }
  return 'memory';
}

Second, if the type is "redis", the factory calls getRedisConfigFromEnv to assemble the full connection configuration from a dozen REDIS_* environment variables, like REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD. This function is responsible for parsing these strings into the structured object the ioredis client needs. If a required variable like REDIS_HOST is missing, it will throw a startup-blocking error to prevent the application from running in a broken state. This keeps all environment-specific logic cleanly isolated in one place.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Simplified for clarity
function getRedisConfigFromEnv(): RedisConfig {
    const host = process.env.REDIS_HOST;
    if (!host) {
        throw new Error('REDIS_HOST is not set!');
    }
    return {
        host,
        port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
        password: process.env.REDIS_PASSWORD,
        db: parseInt(process.env.REDIS_DB ?? '0', 10),
        tls: process.env.REDIS_TLS_ENABLED === 'true',
    };
}

The overall selection logic is straightforward:

graph TD
    A(initializeConversationMemory) --> B{getStorageType()};
    B -- "memory" --> C[new ConversationMemoryManager];
    B -- "redis" --> D{getRedisConfigFromEnv};
    D --> E[new RedisConversationMemoryManager];
    C --> F(Return IConversationMemoryManager);
    E --> F;

This clean predicate, driven entirely by the environment, lets us switch from a local, ephemeral store to a shared, persistent one without touching a single line of application code. It’s a core principle that simplifies both development and our deployment pipeline, a topic we touch on in How We Test NeuroLink: 20 Continuous Test Suites and Counting.

The Redis Race Condition: storeToolExecution and flushPendingToolData

Moving to a distributed backend like Redis introduces problems the simple in-memory Map never has. The most subtle one is handling tool use. An agentic workflow that uses tools generates at least two messages in rapid succession: the assistant’s tool-call message and the user’s tool-result message. If these are written to Redis in two separate SET commands, a different server process could read the conversation history between those two writes. It would see the tool call but not the result, leading to a corrupted state and likely causing the agent to repeat the call or fail entirely.

The RedisConversationMemoryManager solves this with a two-phase commit strategy. It adds a storeToolExecution method that doesn’t write directly to Redis. Instead, it stages the tool call and result messages in a private, in-memory Map called pendingToolExecutions, keyed by session ID. This operation is synchronous and very fast.

1
2
3
4
5
6
7
8
9
10
11
12
13
// A simplified view of the problem: two separate writes create a race window
async function storeTurn_RACE_CONDITION(sessionId, message) {
  const conversation = await redis.get(sessionId);
  conversation.messages.push(message);
  await redis.set(sessionId, conversation); // Write #1: tool_calls
  // ANOTHER PROCESS CAN READ THE INCOMPLETE STATE HERE
}

async function storeToolResult_RACE_CONDITION(sessionId, resultMessage) {
    const conversation = await redis.get(sessionId);
    conversation.messages.push(resultMessage);
    await redis.set(sessionId, conversation); // Write #2: tool_results
}

The actual implementation avoids this. The staged data is only written to Redis when the next regular storeConversationTurn call occurs. That function’s logic includes a call to flushPendingToolData, which pushes the pending tool messages into the history and writes the entire, consistent state to Redis in a single, atomic operation. This guarantees that no other process can ever observe an incomplete tool-use pair. The flushPendingToolData method checks the pendingToolExecutions map, and if it finds data for the current session, it prepends those messages to the new ones being stored and then clears the pending entry.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Simplified logic inside RedisConversationMemoryManager
private pendingToolExecutions = new Map<string, ChatMessage[]>();

private flushPendingToolData(sessionId: string, newMessages: ChatMessage[]): ChatMessage[] {
    const pending = this.pendingToolExecutions.get(sessionId);
    if (pending) {
        this.pendingToolExecutions.delete(sessionId);
        return [...pending, ...newMessages];
    }
    return newMessages;
}

// In storeConversationTurn:
// const messagesToStore = this.flushPendingToolData(sessionId, newMessages);
// await redis.set(getSessionKey(sessionId), serializeConversation(messagesToStore));

This ensures that storeConversationTurn becomes the single, atomic commit point for all session modifications, elegantly solving the race condition without resorting to expensive Redis locking.

The Summarization Engine: A Shared Responsibility

While the storage mechanism differs, both memory managers share the responsibility of keeping conversation history from exceeding the model’s context window. They delegate this task to a common SummarizationEngine.

When storeConversationTurn is called, both implementations invoke checkAndSummarize. This method, part of the shared engine, performs a series of steps:

  1. It estimates the token count of the current conversation history using estimateTokens.
  2. It compares this count to a configurable limit, determined by getEffectiveTokenThreshold. This threshold is dynamically calculated based on the MEMORY_THRESHOLD_PERCENTAGE constant (e.g., 75%) and the specific context window of the model being used.
  3. If the threshold is exceeded, it triggers generateSummary.

The summary generation itself uses a sophisticated prompt-building function, buildSummarizationPrompt, which constructs a detailed request for the LLM to condense the history. This prompt instructs the model to identify key entities, user intent, and unresolved questions to create a dense, useful summary. The function createSummarySystemMessage then wraps the LLM’s output in a ChatMessage object with the system role. This process is critical for maintaining long-running conversations and is part of our broader strategy for managing context, which we detail in Four-stage context compaction: what runs when the model window fills up.

1
2
3
4
5
6
7
8
function buildSummarizationPrompt(messages: ChatMessage[]): string {
    const history = messages.map(m => `${m.role}: ${m.content}`).join('\n');
    return `Please summarize the following conversation. Identify the main topics, key decisions, and any unresolved questions.
---
${history}
---
Summary:`;
}

The generateSummary function takes the oldest messages, generates the summary, and then replaces them with the single new summary message. This new, shorter message list is then handed back to the memory manager to be persisted. By delegating this common, complex logic to the SummarizationEngine, we keep the ConversationMemoryManager and RedisConversationMemoryManager focused on their primary job: storage.

Redis-Only Capabilities

The RedisConversationMemoryManager also adds capabilities that wouldn’t make sense for a transient, in-memory store. It manages a reference-counted connection pool via getPooledRedisClient and releasePooledRedisClient to efficiently handle connections from hundreds of concurrent agent processes. This avoids the latency of establishing new TCP connections for every request. The isRedisHealthy function periodically sends a PING command to the server to verify its status, which feeds into the application’s overall getHealthStatus check.

It also introduces an async generateConversationTitle method. After a few turns, it dispatches a background job using setImmediate to generate a descriptive title for the session (e.g., “API Key Rate Limit Issue”) without blocking the main conversation flow. This title is then stored with the session metadata, providing a much better user experience for browsing session history via the getUserAllSessionsHistory method, a feature the simple in-memory manager has no need for.

1
2
3
4
5
6
7
8
9
10
11
// Simplified title generation logic
public generateConversationTitle(sessionId: string): void {
    setImmediate(async () => {
        const messages = await this.getSessionMessages(sessionId);
        if (messages && messages.length > 3) {
            const context = this.buildContextForTitling(messages);
            const title = await this.llm.generate(context); // Make a cheap LLM call
            await this.redis.hset(getSessionKey(sessionId), 'title', title);
        }
    });
}

Finally, the Redis manager provides methods for user-centric session management, such as getUserSessions, which uses scanKeys to find all sessions associated with a specific userId. This is impossible in the single-process in-memory store but is essential for multi-session user experiences. These enhancements are possible because the IConversationMemoryManager contract provides a solid foundation, while the factory pattern gives us the flexibility to layer on backend-specific features where they add the most value.



Related posts:

This post is licensed under CC BY 4.0 by the author.