Long-running agents fail in predictable ways. They forget context after 50 turns. They repeat tool calls they already made. They lose track of what they learned three subtasks ago. The solution is an explicit memory architecture: conversation history with summarization, a short-term working memory for the current task, and a long-term episodic memory that persists across sessions. This lesson builds each layer in Node.js and shows how to connect them to MCP tool calls so the agent carries relevant context into every decision.

Layer 1: Conversation History with Rolling Summarization
import Anthropic from '@anthropic-ai/sdk';
class ConversationMemory {
#messages = [];
#summary = null;
#maxMessages = 20;
#anthropic;
constructor(anthropic) {
this.#anthropic = anthropic;
}
add(message) {
this.#messages.push(message);
if (this.#messages.length > this.#maxMessages) {
this.#compactHistory();
}
}
async #compactHistory() {
const toCompress = this.#messages.splice(0, 10);
const summaryReq = await this.#anthropic.messages.create({
model: 'claude-3-5-haiku-20241022',
max_tokens: 300,
messages: [
...toCompress,
{ role: 'user', content: 'Summarize the above conversation in 3-5 bullet points, preserving all decisions made and tool call results.' },
],
});
const newSummary = summaryReq.content[0].text;
this.#summary = this.#summary
? `Previous summary:\n${this.#summary}\n\nUpdated:\n${newSummary}`
: newSummary;
}
toMessages() {
if (!this.#summary) return this.#messages;
return [
{ role: 'user', content: `[Conversation history summary]\n${this.#summary}` },
{ role: 'assistant', content: 'Understood, I have the context from our previous exchange.' },
...this.#messages,
];
}
}
Layer 2: Working Memory – Task State Tracking
// Working memory tracks what the agent knows about the current task
class WorkingMemory {
#state = new Map();
set(key, value) {
this.#state.set(key, { value, timestamp: Date.now() });
}
get(key) {
return this.#state.get(key)?.value;
}
toContext() {
if (this.#state.size === 0) return '';
const lines = [...this.#state.entries()].map(
([k, v]) => `- ${k}: ${JSON.stringify(v.value)}`
);
return `[Working memory]\n${lines.join('\n')}\n`;
}
}
// Use in tool call results to persist findings
const memory = new WorkingMemory();
// After searching products, remember what was found
const products = await mcp.callTool({ name: 'search_products', arguments: { query: 'laptop' } });
memory.set('searched_products', JSON.parse(products.content[0].text));
// When calling the next tool, include working memory in the system prompt
const systemPrompt = `You are a research assistant.
${memory.toContext()}
Use the above context to avoid repeating work you have already done.`;

Layer 3: Episodic Memory – Cross-Session Persistence
// Episodic memory stores session outcomes in a database
// Simple implementation using a JSON file; use Redis or PostgreSQL in production
import fs from 'node:fs';
import path from 'node:path';
class EpisodicMemory {
#storePath;
#episodes = [];
constructor(userId, storePath = './memory-store') {
this.#storePath = path.join(storePath, `${userId}.json`);
this.#load();
}
#load() {
try {
this.#episodes = JSON.parse(fs.readFileSync(this.#storePath, 'utf8'));
} catch {
this.#episodes = [];
}
}
async save(episode) {
this.#episodes.push({
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
...episode,
});
// Keep last 50 episodes
if (this.#episodes.length > 50) this.#episodes.shift();
await fs.promises.writeFile(this.#storePath, JSON.stringify(this.#episodes, null, 2));
}
toContextString(maxEpisodes = 5) {
if (this.#episodes.length === 0) return '';
const recent = this.#episodes.slice(-maxEpisodes);
const lines = recent.map(e => `[${e.timestamp}] ${e.task}: ${e.outcome}`);
return `[Previous session memory]\n${lines.join('\n')}\n`;
}
}
// After each task session
await episodicMemory.save({
task: 'Product research for Q1 laptop category',
outcome: 'Found 12 products, top pick: ThinkPad X1 Carbon',
toolsUsed: ['search_products', 'get_pricing', 'check_availability'],
});
Tool Call Deduplication
// Prevent the agent from calling the same tool with the same args twice
class ToolCallCache {
#cache = new Map();
key(name, args) {
return `${name}:${JSON.stringify(args)}`;
}
has(name, args) {
return this.#cache.has(this.key(name, args));
}
get(name, args) {
return this.#cache.get(this.key(name, args));
}
set(name, args, result) {
this.#cache.set(this.key(name, args), result);
}
}
const toolCache = new ToolCallCache();
// Wrap MCP callTool with cache
async function callToolCached(mcp, name, args) {
if (toolCache.has(name, args)) {
console.error(`[cache hit] ${name}`);
return toolCache.get(name, args);
}
const result = await mcp.callTool({ name, arguments: args });
toolCache.set(name, args, result);
return result;
}
Checkpoint and Resume Pattern
// Save agent state to disk so it can be resumed after interruption
class AgentCheckpoint {
#path;
constructor(sessionId) {
this.#path = `./checkpoints/${sessionId}.json`;
}
async save(state) {
await fs.promises.mkdir('./checkpoints', { recursive: true });
await fs.promises.writeFile(this.#path, JSON.stringify(state, null, 2));
}
async load() {
try {
return JSON.parse(await fs.promises.readFile(this.#path, 'utf8'));
} catch {
return null;
}
}
async clear() {
await fs.promises.unlink(this.#path).catch(() => {});
}
}
// Usage in agent loop
const checkpoint = new AgentCheckpoint(sessionId);
const savedState = await checkpoint.load();
const memory = savedState
? ConversationMemory.fromJSON(savedState.memory)
: new ConversationMemory(anthropic);
// ... run agent loop ...
// After each turn, save checkpoint
await checkpoint.save({ memory: memory.toJSON(), workingMemory: workingMemory.toJSON() });
What to Build Next
- Add working memory to your most-used MCP agent: track what the agent has searched and found in the current session. Check if it reduces repeated tool calls.
- Implement the rolling summarization in
ConversationMemoryand test it with a 30-turn conversation. Verify the summary captures all key tool call results.
nJoy 😉
