Module 6: Memory Systems for Agents

8. Project: Research Agent v3 — Persistent Memory

Project Overview

In Module 5 you built Research Agent v2: planning with priorities, reflection against 5 criteria, conditional re-planning, and reasoning traces. It's an agent that thinks, researches, evaluates its own answer, and improves when it detects gaps. But it has a problem you discover the moment you use it for real: it remembers nothing.

If the research gets interrupted at step 7 of 12 (because your laptop went to sleep, the server restarted, you closed the terminal), you lose steps 1-6. If you've told it 5 times that you prefer reports in bullet points, it asks you again the sixth time. If you run two research sessions back to back, the second one has no context from the first.

In this project you turn v2 into Research Agent v3 by adding three memory systems:

  1. Checkpointing with PostgresSaver: Every step persists in PostgreSQL. If the process dies, you pick up exactly where you left off — plan, partial data, reasoning trace, all intact.

  2. Long-term memory: The agent remembers the user's preferences across different sessions. Output format, preferred sources, topics researched. They get injected automatically into the system prompt.

  3. Conversation management: For long research sessions (30+ messages), it trims old messages and generates a summary to keep the context window manageable.

  4. Time-travel: Inspect any past step of a research run and replay from a specific checkpoint for debugging.

Estimated time: 60-90 minutes (includes bringing up Docker for Postgres).


Project Goal

Extend Research Agent v2 (M5) with durable checkpointing, long-term memory for preferences, conversation management with trimming, and time-travel debugging.

By the end you'll be able to:

  • Replace MemorySaver with PostgresSaver without changing the agent's logic
  • Implement long-term memory with InMemoryStore for user preferences
  • Implement trimming + summarization for long conversations
  • Inspect the state history and replay from checkpoints
  • Verify that a research run survives a process restart

What Changes vs v2 (M5)

Research Agent v2's foundation stays — planning, research, analysis, synthesis, reflection, re-planning. You don't rewrite those nodes. But you add three layers of memory.

New state fields

FieldTypeWhat for
user_idstrIdentifying the user for long-term memory
conversation_summarystrA summary of old, trimmed messages
preferences_loadedboolAvoiding loading preferences more than once

New components

ComponentTypeWhat it does
PostgresSaverCheckpointerPersists state in PostgreSQL
InMemoryStoreStoreCross-session user preferences
summarize_nodeNodeTrims + summarizes messages when they exceed the limit
time_travel_*FunctionsCheckpoint inspection and replay

Changes to the flow

v2: planning → research ⇄ analysis → synthesis → reflection ─┐
                ▲                                              │
                └──── replan ◄─────────────────────────────────┘

v3: [summarize] → planning → research ⇄ analysis → synthesis → reflection ─┐
         ▲           │                                                      │
    trim + resume    │                                                      │
                loads prefs                                                 │
                from InMemoryStore                                          │
                     └──────────────── replan ◄─────────────────────────────┘

    + PostgresSaver (every step persists in PostgreSQL)
    + InMemoryStore (cross-session preferences)
    + time-travel (inspection and replay)

Technical Specifications

Stack

TechnologyVersionUse
Python3.11+Runtime
langchain, langchain-openaiv1.2+LLM, prompts
langgraphv1.0+StateGraph, checkpointing, store
langgraph-checkpoint-postgreslatestPostgresSaver
tavily-pythonlatestWeb search
DockeranyLocal PostgreSQL
pip install langchain langchain-openai langgraph langgraph-checkpoint-postgres tavily-python python-dotenv

Docker Compose for PostgreSQL

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    container_name: research_agent_postgres
    environment:
      POSTGRES_USER: agents
      POSTGRES_PASSWORD: agents_secret
      POSTGRES_DB: agents_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:
docker compose up -d
docker compose ps

Environment variables

# .env
OPENAI_API_KEY=sk-proj-your-api-key-here
TAVILY_API_KEY=tvly-your-api-key-here
DATABASE_URL=postgresql://agents:agents_secret@localhost:5432/agents_db

v3 Architecture

                                       quality >= threshold
                                  ┌───────────────────────────────────┐
                                  │                                   ▼
[START] → [SUMMARIZE] → [PLANNING] → [RESEARCH] ⇄ [ANALYSIS] → [SYNTHESIS] → [REFLECTION] → [END]
               │             │         ▲  score<0.7   │                          │
          trim + resume      │         │  & iter<max  │                          │ quality < threshold
                             │         └──────────────┘                          │ & revisions < max
                    loads preferences                                            │
                    from InMemoryStore                                           │
                             └──────────────── [RE-PLAN] ◄───────────────────────┘

┌─────────────────────────────────────────────────────────────────────────┐
│  PERSISTENCE                                                            │
│  PostgresSaver        InMemoryStore        Time-Travel                  │
│  (checkpoints/step)   (cross-session prefs) (get_state_history/replay) │
└─────────────────────────────────────────────────────────────────────────┘

Three levels of memory:

  1. Checkpointing (PostgresSaver): Every node generates a checkpoint in Postgres. If the process dies between research and analysis, on restart it continues from research with everything intact.

  2. Long-term (InMemoryStore): Preferences stored under a user_id namespace. They get loaded at the start of every run and injected into the planning node's system prompt.

  3. Conversation management (summarize_node): Before planning, it checks whether the history exceeds a limit. If it does, it trims the old messages and generates a compact summary.


Step 1: Add PostgresSaver

The simplest and most impactful change. The same API as MemorySaver, durable persistence.

from dotenv import load_dotenv
load_dotenv()

import os
from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = os.environ["DATABASE_URL"]


def create_checkpointer():
    """PostgresSaver if there's a DB, MemorySaver if not."""
    if os.environ.get("DATABASE_URL"):
        checkpointer = PostgresSaver.from_conn_string(DB_URI)
        checkpointer.setup()
        return checkpointer
    from langgraph.checkpoint.memory import MemorySaver
    return MemorySaver()

In v2 you compiled without a checkpointer. In v3:

checkpointer = create_checkpointer()
agent_v3 = build_research_agent_v3().compile(
    checkpointer=checkpointer,
    store=store,
)

Every conversation uses a thread_id that isolates its state:

config = {"configurable": {"thread_id": "research-session-001"}}
result = agent_v3.invoke(initial_state, config)

If the process gets interrupted, you invoke again with the same thread_id and the agent continues from the last checkpoint — PostgresSaver recovers it automatically.


Step 2: Implement Long-term Memory

PostgresSaver solves "don't lose my work." Long-term memory solves "remember who I am."

The store and preference functions

from langgraph.store.memory import InMemoryStore

store = InMemoryStore()


def save_user_preferences(st, user_id: str, preferences: dict):
    existing = load_user_preferences(st, user_id)
    merged = {**existing, **preferences}
    st.put(
        namespace=(user_id, "preferences"),
        key="current",
        value={"data": merged},
    )
    return merged


def load_user_preferences(st, user_id: str) -> dict:
    try:
        item = st.get(namespace=(user_id, "preferences"), key="current")
        if item and item.value:
            return item.value.get("data", {})
    except Exception:
        pass
    return {}


def save_research_history(st, user_id: str, query: str, summary: str):
    from datetime import datetime, timezone
    history = load_research_history(st, user_id)
    history.append({
        "query": query,
        "summary": summary[:500],
        "timestamp": datetime.now(timezone.utc).isoformat(),
    })
    if len(history) > 20:
        history = history[-20:]
    st.put(
        namespace=(user_id, "research_history"),
        key="all",
        value={"entries": history},
    )


def load_research_history(st, user_id: str) -> list:
    try:
        item = st.get(namespace=(user_id, "research_history"), key="all")
        if item and item.value:
            return item.value.get("entries", [])
    except Exception:
        pass
    return []

Injecting preferences into the planning node

v2's planning doesn't know the user. In v3, it loads preferences and history:

def planning_node(state: dict, store) -> dict:
    """Generate a research plan — with the user's preferences."""
    query = ""
    for msg in reversed(state["messages"]):
        if isinstance(msg, HumanMessage):
            query = msg.content
            break

    user_id = state.get("user_id", "default")
    preferences = load_user_preferences(store, user_id)
    past_research = load_research_history(store, user_id)
    reflection_notes = state.get("reflection_notes", [])
    context = _build_context_prefix(state)

    pref_context = ""
    if preferences:
        pref_context = (
            "\nUSER PREFERENCES:\n"
            f"- Format: {preferences.get('output_format', 'not specified')}\n"
            f"- Sources: {preferences.get('preferred_sources', 'not specified')}\n"
            f"- Detail: {preferences.get('detail_level', 'not specified')}\n"
        )

    history_context = ""
    if past_research:
        topics = [r["query"][:80] for r in past_research[-3:]]
        history_context = (
            f"\nPREVIOUS TOPICS: {json.dumps(topics, ensure_ascii=False)}\n"
            "Avoid repeating research that's already been done.\n"
        )

    replan_context = ""
    if reflection_notes:
        replan_context = (
            f"\nRE-PLANNING:\n"
            f"Notes: {json.dumps(reflection_notes, ensure_ascii=False)}\n"
            "Generate DIFFERENT sub-questions that cover the gaps.\n"
        )

    response = model.invoke([
        SystemMessage(content=(
            f"{context}"
            "You are an expert researcher. Break the question into "
            "2-5 sub-questions with priorities.\n"
            f"{pref_context}{history_context}{replan_context}\n"
            "Answer with ONLY JSON:\n"
            '{"sub_questions": ["q1","q2"], "priority_order": [0,1], '
            '"success_criteria": "...", "reasoning": "..."}'
        )),
        HumanMessage(content=query),
    ])

    parsed = _parse_json(response.content)
    sub_qs = parsed.get("sub_questions", [query])
    prio = parsed.get("priority_order", list(range(len(sub_qs))))
    criteria = parsed.get("success_criteria", "A complete answer")

    plan = {
        "main_query": query,
        "sub_questions": sub_qs,
        "completed_questions": state.get("plan", {}).get("completed_questions", []),
        "priority_order": prio,
        "success_criteria": criteria,
    }

    text = f"Plan: {query}\nCriterion: {criteria}\n"
    for idx in prio:
        if idx < len(sub_qs):
            text += f"  [P{prio.index(idx)+1}] {sub_qs[idx]}\n"

    return {
        "plan": plan,
        "preferences_loaded": True,
        "messages": [AIMessage(content=text)],
        "reasoning_trace": _trace(
            state, "planning",
            "re-plan" if reflection_notes else "initial_plan",
            parsed.get("reasoning", ""),
            preferences_loaded=bool(preferences),
        ),
    }

The planning_node(state, store) signature is the key: LangGraph detects the store parameter and injects it automatically. The nodes that don't need the store keep their original (state) -> dict signature.

Detecting preferences after the research run

def extract_and_save_preferences(st, user_id, messages):
    keywords = {
        "bullet points": {"output_format": "bullet_points"},
        "markdown format": {"output_format": "markdown"},
        "short answers": {"detail_level": "concise"},
        "detailed answers": {"detail_level": "detailed"},
        "academic sources": {"preferred_sources": "academic"},
        "official sources": {"preferred_sources": "official"},
    }
    for msg in messages:
        if isinstance(msg, HumanMessage):
            lower = msg.content.lower()
            for kw, pref in keywords.items():
                if kw in lower:
                    save_user_preferences(st, user_id, pref)

A simple keyword-based detector. In production you'd use an LLM, but for the project this demonstrates the pattern without adding latency.


Step 3: Conversation Management

Long research sessions generate a lot of messages. A run with 3 iterations + reflection + re-plan generates ~20 messages. Two runs back to back: 40. The context window fills up and the cost grows.

The summarization node

It runs at the start, before planning. If there are too many messages, it trims the old ones and generates a summary.

from langchain_core.messages import RemoveMessage

MAX_MESSAGES = 20
SUMMARY_THRESHOLD = 15


def summarize_node(state: dict) -> dict:
    messages = state.get("messages", [])
    existing_summary = state.get("conversation_summary", "")

    if len(messages) <= SUMMARY_THRESHOLD:
        return {}

    messages_to_summarize = messages[:-10]

    summary_input = ""
    if existing_summary:
        summary_input = f"PREVIOUS SUMMARY: {existing_summary}\n\n"
    summary_input += "MESSAGES TO SUMMARIZE:\n"
    for msg in messages_to_summarize:
        role = "User" if isinstance(msg, HumanMessage) else "Agent"
        summary_input += f"[{role}]: {msg.content[:200]}\n"

    response = model.invoke([
        SystemMessage(content=(
            "Summarize the conversation in 3-5 sentences. Capture:\n"
            "- What was researched and what was found\n"
            "- What the user prefers\n"
            "- Where the research currently stands\n"
            "Be concise."
        )),
        HumanMessage(content=summary_input),
    ])

    delete_msgs = [RemoveMessage(id=m.id) for m in messages_to_summarize if m.id]

    return {
        "conversation_summary": response.content,
        "messages": delete_msgs,
        "reasoning_trace": _trace(
            state, "summarize", "trim_and_summarize",
            f"Trimmed {len(messages_to_summarize)} msgs",
        ),
    }

RemoveMessage is a special LangGraph type. When a node returns RemoveMessage(id=msg.id) in the messages list, the add_messages reducer removes that message from the state. It's declarative trimming.

Injecting the summary as context

The nodes that invoke the model include the summary to maintain coherence:

def _build_context_prefix(state: dict) -> str:
    summary = state.get("conversation_summary", "")
    if not summary:
        return ""
    return (
        f"PREVIOUS CONTEXT:\n{summary}\n\n"
        "Use this context. Don't repeat research that's already been done.\n\n"
    )

Step 4: Time-travel Capabilities

With PostgresSaver, every step generates a checkpoint. Time-travel lets you navigate those checkpoints for debugging and replay.

Inspecting the history

def inspect_investigation(agent, thread_id: str):
    config = {"configurable": {"thread_id": thread_id}}
    history = list(agent.get_state_history(config))

    print(f"\n{'='*60}")
    print(f"  RESEARCH RUN: {thread_id}")
    print(f"  Total checkpoints: {len(history)}")
    print(f"{'='*60}")

    for i, snapshot in enumerate(reversed(history)):
        values = snapshot.values
        node = snapshot.metadata.get("source", "unknown")
        step = snapshot.metadata.get("step", "?")
        score = values.get("quality_score", 0.0)
        data_count = len(values.get("research_data", []))

        print(f"\n  [{step}] {node} — score={score:.2f}, data={data_count}")

        trace = values.get("reasoning_trace", [])
        if trace:
            last = trace[-1]
            print(f"      trace: {last.get('node', '?')}{last.get('decision', '?')}")

    print(f"\n{'='*60}")
    return history

Replaying from a checkpoint

If the research took a wrong turn, you can go back to a point and continue differently:

def replay_from_checkpoint(agent, thread_id: str, checkpoint_index: int):
    """checkpoint_index: 0 = most recent, 1 = the one before, etc."""
    config = {"configurable": {"thread_id": thread_id}}
    history = list(agent.get_state_history(config))

    if checkpoint_index >= len(history):
        print(f"There are only {len(history)} checkpoints.")
        return None, None

    target = history[checkpoint_index]
    print(f"  Replay from step {target.metadata.get('step', '?')}")
    print(f"  Node: {target.metadata.get('source', '?')}")
    return target.values, target.config


def fork_investigation(agent, thread_id: str, new_thread_id: str, checkpoint_index: int):
    """Create a new research run from an existing checkpoint."""
    state, _ = replay_from_checkpoint(agent, thread_id, checkpoint_index)
    if state is None:
        return None

    new_config = {"configurable": {"thread_id": new_thread_id}}
    agent.update_state(new_config, state)
    print(f"  Fork created: {new_thread_id}{thread_id}[{checkpoint_index}]")
    return new_config


def compare_investigations(agent, thread_ids: list[str]):
    print(f"\n{'='*60}")
    print(f"  COMPARISON")
    print(f"{'='*60}")

    for tid in thread_ids:
        state = agent.get_state({"configurable": {"thread_id": tid}})
        if state.values:
            v = state.values
            print(f"\n  {tid}")
            print(f"    Score: {v.get('quality_score', 0.0):.2f}")
            print(f"    Iters: {v.get('iteration_count', 0)}, "
                  f"Re-plans: {v.get('plan_revisions', 0)}")
            print(f"    Data: {len(v.get('research_data', []))}")

    print(f"\n{'='*60}")

Step 5: Update the Graph

Extend the state

from typing import Annotated, TypedDict
from langchain_core.messages import BaseMessage
from langgraph.graph.message import add_messages


class ResearchPlan(TypedDict):
    main_query: str
    sub_questions: list[str]
    completed_questions: list[str]
    priority_order: list[int]
    success_criteria: str


class AgentState(TypedDict):
    # v1/v2 fields
    messages: Annotated[list[BaseMessage], add_messages]
    plan: ResearchPlan
    iteration_count: int
    max_iterations: int
    research_data: list[str]
    quality_score: float
    final_answer: str
    metadata: dict
    reflection_notes: list[str]
    plan_revisions: int
    max_plan_revisions: int
    reasoning_trace: list[dict]

    # v3: Memory Systems
    user_id: str
    conversation_summary: str
    preferences_loaded: bool

Build the graph

from langgraph.graph import StateGraph, START, END


def should_continue_research(state: dict) -> str:
    if state.get("quality_score", 0.0) >= 0.7:
        return "synthesis"
    if state.get("iteration_count", 0) >= state.get("max_iterations", 3):
        return "synthesis"
    return "research"


def should_replan_or_finish(state: dict) -> str:
    if state.get("quality_score", 0.0) >= 0.7:
        return "end"
    if state.get("plan_revisions", 0) < state.get("max_plan_revisions", 2):
        return "replan"
    return "end"


def build_research_agent_v3():
    graph = StateGraph(AgentState)

    graph.add_node("summarize", summarize_node)
    graph.add_node("planning", planning_node)
    graph.add_node("research", research_node)
    graph.add_node("analysis", analysis_node)
    graph.add_node("synthesis", synthesis_node)
    graph.add_node("reflection", reflection_node)
    graph.add_node("replan", replan_node)

    graph.add_edge(START, "summarize")
    graph.add_edge("summarize", "planning")
    graph.add_edge("planning", "research")
    graph.add_edge("research", "analysis")
    graph.add_edge("synthesis", "reflection")
    graph.add_edge("replan", "research")

    graph.add_conditional_edges(
        "analysis", should_continue_research,
        {"research": "research", "synthesis": "synthesis"},
    )
    graph.add_conditional_edges(
        "reflection", should_replan_or_finish,
        {"end": END, "replan": "replan"},
    )

    return graph

The new summarize node gets inserted between START and planning. v2's nodes (research, analysis, synthesis, reflection, replan) don't change — only planning gets the store as a second argument.


The Complete Research Agent v3

The code from steps 1-5 is everything you need. v2's nodes (research_node, analysis_node, synthesis_node, reflection_node, replan_node) stay identical — copy them from the M5 project. The _parse_json and _trace helpers stay too.

What's new in v3:

  1. create_checkpointer() — PostgresSaver or MemorySaver depending on the environment
  2. save/load_user_preferences and save/load_research_history — long-term memory
  3. summarize_node — trimming + summarization
  4. The updated planning_node — accepts store, loads preferences and history
  5. inspect_investigation, replay_from_checkpoint, fork_investigation — time-travel

To run it end-to-end:

from dotenv import load_dotenv
load_dotenv()

import os
import json
from datetime import datetime, timezone
from langchain.chat_models import init_chat_model
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
from langchain_community.tools.tavily_search import TavilySearchResults
from langgraph.checkpoint.postgres import PostgresSaver
from langgraph.store.memory import InMemoryStore

model = init_chat_model("openai:gpt-4.1-mini")
search_tool = TavilySearchResults(max_results=3)
store = InMemoryStore()
DB_URI = os.environ["DATABASE_URL"]

# ... (include: AgentState, ResearchPlan, _parse_json, _trace,
#      _build_context_prefix, every memory function,
#      every node, build_research_agent_v3) ...

with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()
    agent_v3 = build_research_agent_v3().compile(
        checkpointer=checkpointer,
        store=store,
    )

    def run_research_v3(
        query: str,
        user_id: str = "default",
        thread_id: str | None = None,
        max_iterations: int = 3,
        max_plan_revisions: int = 2,
        verbose: bool = True,
    ) -> dict:
        if thread_id is None:
            thread_id = f"research-{datetime.now().strftime('%Y%m%d-%H%M%S')}"

        config = {"configurable": {"thread_id": thread_id}}

        # Detect whether there's previous state (resume)
        existing = agent_v3.get_state(config)
        resuming = (existing.values
                    and existing.values.get("plan", {}).get("main_query"))

        if resuming:
            if verbose:
                score = existing.values.get("quality_score", 0.0)
                data = len(existing.values.get("research_data", []))
                print(f"\n  Resuming: {thread_id} (score={score:.2f}, data={data})")

            result = agent_v3.invoke(
                {"messages": [HumanMessage(content=query)]},
                config,
            )
        else:
            initial_state = {
                "messages": [HumanMessage(content=query)],
                "plan": {
                    "main_query": "", "sub_questions": [],
                    "completed_questions": [], "priority_order": [],
                    "success_criteria": "",
                },
                "iteration_count": 0,
                "max_iterations": max_iterations,
                "research_data": [],
                "quality_score": 0.0,
                "final_answer": "",
                "metadata": {},
                "reflection_notes": [],
                "plan_revisions": 0,
                "max_plan_revisions": max_plan_revisions,
                "reasoning_trace": [],
                "user_id": user_id,
                "conversation_summary": "",
                "preferences_loaded": False,
            }

            if verbose:
                print(f"\n{'='*60}")
                print(f"  Research Agent v3")
                print(f"  Query: {query}")
                print(f"  User: {user_id} | Thread: {thread_id}")
                print(f"{'='*60}")

            result = agent_v3.invoke(initial_state, config)

        # Post-run: save to long-term memory
        save_research_history(store, user_id, query, result.get("final_answer", "")[:500])
        extract_and_save_preferences(store, user_id, result.get("messages", []))

        if verbose:
            score = result.get("quality_score", 0.0)
            prefs = load_user_preferences(store, user_id)
            history = load_research_history(store, user_id)
            print(f"\n  Score: {score:.2f} | Thread: {thread_id}")
            print(f"  Preferences: {list(prefs.keys()) if prefs else 'none'}")
            print(f"  Research runs: {len(history)}")

        return result

    # ── Execution ────────────────────────────────────────────

    if __name__ == "__main__":
        save_user_preferences(store, "mike", {
            "output_format": "bullet_points",
            "preferred_sources": "official",
            "detail_level": "detailed",
        })

        result = run_research_v3(
            query="What are the trends in AI agents and how do LangGraph and CrewAI compare?",
            user_id="mike",
            thread_id="demo-v3-001",
        )

        print(f"\n{result.get('final_answer', 'No answer')[:500]}")
        inspect_investigation(agent_v3, "demo-v3-001")

Expected output (schematic)

============================================================
  Research Agent v3
  Query: What are the trends in AI agents...?
  User: mike | Thread: demo-v3-001
============================================================

Plan: 3 sub-questions (trends, LangGraph, CrewAI)
  Preferences loaded: [output_format, preferred_sources, detail_level]

Research → Analysis (iter 1): score=0.45
Research → Analysis (iter 2): score=0.72 → Synthesis
Reflection: score=0.68 → Re-plan #1
Research → Analysis (iter 1): score=0.78 → Synthesis
Reflection: score=0.82 → APPROVED

  Score: 0.82 | Thread: demo-v3-001
  Preferences: ['output_format', 'preferred_sources', 'detail_level']
  Research runs: 1

  RESEARCH RUN: demo-v3-001
  Total checkpoints: 14
  [1] summarize — score=0.00, data=0
  [2] planning — score=0.00, data=0
  ...
  [14] reflection — score=0.82, data=9
============================================================

Recommended Tests

Test 1: The research survives a restart (checkpointing)

# Session 1: run the research
result = run_research_v3(
    query="What is the Model Context Protocol?",
    user_id="test", thread_id="persist-001",
)
# CLOSE the Python process (Ctrl+C or exit())

# Session 2: new terminal, re-import everything, re-create agent_v3
config = {"configurable": {"thread_id": "persist-001"}}
state = agent_v3.get_state(config)
print(f"Plan: {state.values.get('plan', {}).get('main_query', 'EMPTY')}")
print(f"Data: {len(state.values.get('research_data', []))}")

What it validates: The state should show the plan and data — recovered from PostgreSQL after the restart.

Test 2: Cross-session memory (long-term)

save_user_preferences(store, "carlos", {
    "output_format": "bullet_points",
    "preferred_sources": "academic",
})

result1 = run_research_v3(query="What is RAG?", user_id="carlos", thread_id="s-a")
result2 = run_research_v3(query="How does fine-tuning work?", user_id="carlos", thread_id="s-b")

# In session-b, planning should load the preferences from session-a
prefs = load_user_preferences(store, "carlos")
history = load_research_history(store, "carlos")
print(f"Prefs: {prefs}")
print(f"Research runs: {[h['query'] for h in history]}")

What it validates: In session-b, planning loads session-a's preferences. The history shows both research runs.

Test 3: A long conversation (trimming)

thread = "long-convo-test"
queries = ["What is LangGraph?", "How does it compare to CrewAI?",
           "What are the advantages for production?", "Give me more detail"]

for q in queries:
    run_research_v3(query=q, user_id="trim-test", thread_id=thread)
    state = agent_v3.get_state({"configurable": {"thread_id": thread}})
    msgs = len(state.values.get("messages", []))
    has_summary = bool(state.values.get("conversation_summary"))
    print(f"Msgs: {msgs} | Summary: {has_summary}")

What it validates: The message count stabilizes. When it passes SUMMARY_THRESHOLD, a summary appears.

Test 4: Time-travel and debugging

result = run_research_v3(query="The best frameworks for AI agents",
                         user_id="debug", thread_id="debug-001")

history = inspect_investigation(agent_v3, "debug-001")

fork_investigation(agent_v3, "debug-001", "debug-001-fork", checkpoint_index=3)
compare_investigations(agent_v3, ["debug-001", "debug-001-fork"])

What it validates: inspect_investigation shows every step. fork_investigation creates a branch. compare_investigations shows the differences.


Success Criteria

  1. The research survives a restart. Close Python, open a new session, query the thread_id — the plan, data, and score are all intact.

  2. The agent remembers preferences between sessions. Two research runs with the same user_id, a different thread_id. The second loads the first one's preferences.

  3. Long conversations stay within budget. After 4+ invocations, the messages don't exceed MAX_MESSAGES.

  4. You can inspect and replay past steps. inspect_investigation shows the history. fork_investigation creates branches.

  5. The trace includes the v3 nodes. summarize shows up when it triggers. Planning records whether it loaded preferences.


Checklist

  • Docker Compose brings up PostgreSQL
  • create_checkpointer() returns a PostgresSaver
  • The agent compiles with checkpointer and store
  • run_research_v3 accepts user_id and thread_id
  • Invoking with an existing thread_id resumes the research
  • save/load_user_preferences works with InMemoryStore
  • planning_node(state, store) loads preferences and injects them into the prompt
  • save_research_history records past research runs
  • summarize_node trims when messages > SUMMARY_THRESHOLD
  • RemoveMessage removes messages correctly
  • inspect_investigation shows the complete history
  • fork_investigation creates a branch from a checkpoint
  • The state survives a process restart
  • Preferences persist across the same user's threads
  • A long conversation doesn't exceed MAX_MESSAGES

Common Errors

Error 1: PostgreSQL isn't running

Symptom: psycopg.OperationalError: connection refused when compiling.

Cause: Docker Compose isn't up, or port 5432 is taken.

Solution: docker compose up -d && docker compose ps. If the port is taken, change it to 5433:5432 in docker-compose.yml and update the connection string.

Error 2: The agent doesn't resume interrupted research

Symptom: You invoke with the same thread_id after a restart but it starts from zero.

Cause: You pass initial_state instead of letting the checkpointer recover the state, or the thread_id doesn't match exactly.

Solution: run_research_v3 checks for existing state with agent.get_state(config). If there's state with a plan, it invokes with just the new message. Verify the thread_id is the same string.

Error 3: Preferences don't load in planning

Symptom: Planning doesn't mention preferences even though you saved them.

Cause: planning_node doesn't have store as its second parameter, or the user_id doesn't match.

Solution: The signature must be planning_node(state, store). LangGraph injects the store automatically. Verify that user_id in the initial_state matches the one in save_user_preferences.

Error 4: RemoveMessage doesn't remove messages

Symptom: After summarize_node, the count keeps growing.

Cause: The messages have no id. RemoveMessage needs an id to know what to remove.

Solution: LangGraph assigns IDs automatically when messages pass through add_messages. If you create messages manually without going through the state, they have no ID. Check with [m.id for m in state["messages"]].

Error 5: The store loses data across restarts

Symptom: The preferences disappear when you restart the process.

Cause: InMemoryStore lives in RAM — it's lost on restart, just like MemorySaver.

Solution: This is expected. InMemoryStore demonstrates the pattern; in production you'd use a durable backend. For this project, pre-load the preferences at the start of the script. M10 covers persisting the store.

Error 6: Time-travel shows checkpoints with partial values

Symptom: inspect_investigation lists checkpoints but some values are empty.

Cause: The initial_state doesn't include defaults for every AgentState field.

Solution: Verify that initial_state in run_research_v3 has every field. LangGraph persists the complete state in each checkpoint, but if a field was never initialized, it shows up as None.


Connection to M7

Your Research Agent v3 remembers — checkpoints last across restarts, preferences persist across sessions, conversations stay manageable. But the tools are fixed: Tavily for web search, and nothing else. If you need to search GitHub, read an API's documentation, or query a database, you have to add each tool manually.

In Module 7 (MCP — Model Context Protocol):

  • MCP servers expose tools as standardized services. A GitHub server exposes search_repos, read_file. A Postgres server exposes query, list_tables.
  • MCP clients connect the agent to servers dynamically — the agent discovers the available tools at runtime.
  • Research Agent v4 connects to MCP servers to expand its capabilities without modifying the graph.

The transition: "Your agent remembers between sessions (M6) → now give it access to an ecosystem of external tools via MCP (M7)." v3's memory is especially useful with MCP — the agent can remember which servers gave good results and prioritize them.


Resources

  1. LangGraph Persistence Docs — Checkpointing, backends, configuration
  2. PostgresSaver Reference — The complete API
  3. LangGraph Store (Memory) — InMemoryStore, BaseStore, long-term
  4. LangGraph Time Travel — get_state_history, update_state, replay
  5. LangGraph Message Trimming — RemoveMessage, trimming
  6. Docker Compose for PostgreSQL — The official image
  7. psycopg 3 Documentation — The PostgreSQL driver