Whetstone.
Create AgentShort-term memory
Module 1, Lesson 316 min

Short-term memory

An agent with no checkpointer has no memory at all. Every invocation starts from whatever you handed it and ends with the result thrown away. Memory is a component you attach, not a property agents have.

Attaching one

Development and production savers
from langgraph.checkpoint.memory import InMemorySaver

agent = create_agent(model="...", tools=[...], checkpointer=InMemorySaver())
Something that survives a restart
from langgraph.checkpoint.postgres import PostgresSaver

DB_URI = "postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable"
with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
    checkpointer.setup()
    agent = create_agent("gpt-5.5", tools=[...], checkpointer=checkpointer)

Note checkpointer.setup(), which creates the tables. Skipping it is a first-run failure that looks like a connection problem and is not.

The thread id is the whole mechanism

Scoping an invocation to a conversation
thread_config = {"configurable": {"thread_id": "1"}}
response = agent.invoke({"messages": [{"role": "user", "content": "Hi!"}]}, thread_config)

Two levels of nesting, and people flatten it from memory. configurable is the user-supplied compartment of the config; thread_id lives inside it.

What is in state

The default is AgentState, which carries a messages key. You extend it by subclassing:

Adding your own fields
class CustomAgentState(AgentState):
    user_id: str
    preferences: dict

agent = create_agent("gpt-5.5", tools=[...],
                     state_schema=CustomAgentState,
                     checkpointer=InMemorySaver())

Tools read those fields through runtime.state[...] and write them by returning a Command, which is the previous lesson meeting this one.

Trimming, before the middleware exists

History grows, and eventually it stops fitting. Module 3 has a middleware for this. The manual mechanism underneath is worth seeing once, because it explains what the middleware is doing.

Replacing history inside a before_model hook
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES

@before_model
def trim(state: AgentState, runtime: Runtime) -> dict[str, Any] | None:
    messages = state["messages"]
    if len(messages) <= 3:
        return None
    return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}

Two things to take from that. REMOVE_ALL_MESSAGES followed by a spread is a replace, not an append, and the order inside the list is what makes it so. And returning None means “no change”, which is how a hook opts out of doing anything on this turn.

You can also delete individually, with RemoveMessage(id=m.id) per message, when you want to drop a specific exchange rather than compress the lot.

Practice

Try it yourself

Quiz

Predict the second answer

The agent has a checkpointer attached. Two calls, and the config differs.

Same agent, two invocations
cfg_a = {"configurable": {"thread_id": "1"}}
cfg_b = {"configurable": {"thread_id": "2"}}

agent.invoke({"messages": [{"role": "user", "content": "My name is Rai."}]}, cfg_a)
agent.invoke({"messages": [{"role": "user", "content": "What is my name?"}]}, cfg_b)
  1. AIt answers Rai, because the checkpointer stores everything the agent has seen
  2. BIt errors, because thread 2 was never initialised by a prior call
  3. CIt answers Rai, but only if both calls happen inside the same process
  4. DIt cannot answer, because thread 2 has no history containing that name
Show answer

Correct answer: D — It cannot answer, because thread 2 has no history containing that name

A checkpointer scopes memory per thread, and thread 2 is a different conversation with an empty history. The first option is the tempting one because "it has a checkpointer, therefore it remembers" is the intuition everybody arrives with; what a checkpointer actually gives you is persistence keyed by thread, and choosing the key is your job.

Recall

Where the thread id lives

An exact-shape question. The nesting is two levels deep and people flatten it from memory.

Write out the full config dictionary that scopes an invocation to a conversation thread.

Reveal answer

{"configurable": {"thread_id": "1"}}. It is passed as the second argument to invoke, alongside the messages payload. The common error is flattening it to {"thread_id": "1"}, because the configurable nesting looks like ceremony until you remember that config also carries other framework concerns and configurable is specifically the user-supplied compartment.

Quiz

The agent with no checkpointer

An agent is created with no checkpointer at all, and invoked twice in a row with a thread id in the config. What happens on the second call?

  1. AIt errors, because a thread_id in the config has no checkpointer to resolve against
  2. BIt remembers, because compiled graphs hold their state in memory
  3. CIt starts fresh, because there is nothing persisting state between invocations
  4. DIt remembers for the process lifetime, then forgets on restart
Show answer

Correct answer: C — It starts fresh, because there is nothing persisting state between invocations

With no checkpointer there is nowhere for state to be written, so each invocation starts from whatever you passed in. The last option is the seductive one, because it describes InMemorySaver exactly, and people merge "in memory" with "no checkpointer" into one idea. They are different: InMemorySaver is a real checkpointer with a volatile backing store.

Quiz

Clearing history inside a hook

A before_model hook trims history. What does this return value do?

Inside a before_model hook
return {"messages": [RemoveMessage(id=REMOVE_ALL_MESSAGES), *new_messages]}
  1. ADeletes every existing message, then writes new_messages as the history
  2. BAppends new_messages to the existing history, and the RemoveMessage is a no-op sentinel
  3. CDeletes only the messages whose ids appear in new_messages
  4. DRaises, because one update cannot both delete and add messages
Show answer

Correct answer: A — Deletes every existing message, then writes new_messages as the history

REMOVE_ALL_MESSAGES clears the list, and the messages spread after it become the new contents, so the pair is a replace rather than an append. The second option is tempting because the spread syntax reads like an append and the RemoveMessage looks like a no-op sentinel; order inside that list is what makes it a replacement.

Do

Prove thread isolation in a scratch file

Small, offline apart from one model call each, and it makes the abstraction concrete in a way reading cannot.

  • Build an agent with InMemorySaver and no tools.
  • Tell it a fact on thread_id "a", then ask for that fact back on thread_id "a".
  • Ask the same question on thread_id "b" and watch it fail.
  • Now restart the process and re-ask on thread_id "a". Note that the answer is gone, and write one sentence on which of the two saver types you would have needed.
Done whenYou saw the same question answered on one thread and unanswered on another, and you can state in one sentence why the restart wiped it.
Sign in to track your progress →