Tools + Memory
Introduction
In L6 you designed the agent. Now you build its two defining capabilities: tools (how it acts) and memory (how it stays coherent). A reasoner with no tools is just a chatbot; a tool-user with no memory is an amnesiac that re-introduces itself every turn. Together, tools + memory are what turn create_react_agent from a toy into an assistant.
By the end of this lesson your agent will have:
- A real toolset —
search_docs(your Project 1 retriever, wrapped as a tool), plusweb_search,run_code, and a side-effectingsend_message— each with a clear description and a validated input schema. - Robust tool execution — when a tool throws, the error comes back to the model so it can recover, instead of crashing the agent.
- A human-approval gate on the write tool — the guardrail from L6, made real with an interrupt.
- Two-tier memory — a checkpointer (short-term, per-conversation) and a store (long-term, across sessions, recalled by meaning).
Why this is the lesson that makes the agent feel intelligent. Users don't experience your graph; they experience "it used the right tool" and "it remembered me." Both come down to craft: a tool the model can actually understand, and a memory architecture that separates this conversation from what's worth keeping forever. We use the course stack — LangGraph tools + checkpointer + store, with Claude reasoning — and everything is runnable on the scaffold from L6.
Scope: this lesson builds tools and memory in code. Exposing those tools over MCP → L8; evaluating the agent → L9; shipping it → L10.

Anatomy of a Tool the Model Can Use
A LangChain tool is just a function the model can call — but how you define it decides whether the model uses it correctly. The @tool decorator turns a function into a tool: its docstring becomes the description the model reads to decide when to call it, and its type hints become the input schema. For anything non-trivial, define an explicit Pydantic args_schema so inputs are validated and each field carries a description:
# src/agent/tools.py
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from rag.retrieve import retrieve # <- reuse Project 1's retrieve()
class SearchInput(BaseModel):
query: str = Field(description="A natural-language question about company docs, policies, or products.")
@tool(args_schema=SearchInput)
def search_docs(query: str) -> str:
"""Search the internal knowledge base for grounded, cited facts. Use this for ANY
factual question about company documents, policies, or products before answering.
Returns the most relevant passages with their source titles."""
hits = retrieve(query) # hybrid + RRF + rerank (Project 1)
return "\n\n".join(f"[{h['title']}] {h['text']}" for h in hits[:5])
Three things make this tool good: the description tells the model exactly when to use it (it's prompt engineering — the model reads it); the schema validates the input (query: str with a description) so the model can't call it wrong; and it returns token-efficient, source-tagged context (top-5 passages, not the whole corpus). A tool's return value becomes a ToolMessage the model reads on the next loop step.
A Toolset, Not a Tool — and the Read/Write Line
An agent's power comes from a small, well-chosen toolset. Ours has three read tools (safe to call freely) and one write tool (changes the world — handled specially):
@tool
def web_search(query: str) -> str:
"""Search the public web for current information NOT in the internal docs
(news, prices, anything time-sensitive). Prefer search_docs for company facts."""
...
@tool
def run_code(code: str) -> str:
"""Run a short Python snippet in a sandbox for exact math or data manipulation.
Use this instead of doing arithmetic yourself. Returns stdout."""
...
@tool
def send_message(to: str, body: str) -> str:
"""Send an email to a recipient. SIDE EFFECT: this acts on the real world and
REQUIRES human approval before it runs. Use only when explicitly asked to send."""
... # (gated by an approval interrupt — below)
The read/write line is the most important distinction in your toolset. Read tools (search_docs, web_search, run_code) only gather information — the agent can call them as often as it likes. A write tool (send_message, delete_…, create_…) changes the world, and the model can hallucinate its arguments — so every write tool must pass through human approval. Name and describe them so the distinction is obvious to the model and to you. (Keep the set small: more tools = more chances to pick the wrong one. Quality over quantity.)
When Tools Fail — Errors That Don't Crash the Agent
Tools call the real world — APIs time out, queries return nothing, code raises. The naive failure mode is an unhandled exception that kills the whole agent run. The right behavior is to send the error back to the model as a ToolMessage — so the agent sees the failure and can recover (retry with different args, try another tool, or tell the user). LangGraph's ToolNode does this with handle_tool_errors:
from langgraph.prebuilt import ToolNode
# create_react_agent uses a ToolNode internally; you can pass error handling through it.
tool_node = ToolNode(
[search_docs, web_search, run_code, send_message],
handle_tool_errors=True, # catch exceptions -> ToolMessage('Error: ...') -> model recovers
)
# handle_tool_errors can also be: a string (custom message), a callable(exc)->str,
# or an exception type (catch only that one). NOTE: in recent LangGraph it is OFF by
# default — enable it explicitly for production agents.
For flaky failures (a transient network blip), add a RetryPolicy so the node retries with exponential backoff before surfacing the error:
from langgraph.pregel import RetryPolicy
# attach to the tools node so transient failures retry automatically
graph.add_node("tools", tool_node, retry=RetryPolicy(max_attempts=3))
The principle: a tool failure is information for the model, not a crash. A well-built agent treats a failed tool call like a human treats a 404 — it adapts. (And write a clear error message — the model reads it, so "Error: no document matched 'refund'; try a broader query" helps it recover; a bare stack trace doesn't.)
Human-in-the-Loop for the Write Tool
The L6 guardrail — human approval before any side effect — becomes real here. The model proposes a send_message call, but before it executes, the agent pauses and surfaces the exact arguments for a human to approve, edit, or reject. LangGraph's interrupt makes this trivial (it relies on the checkpointer to pause/resume):
from langgraph.types import interrupt
@tool
def send_message(to: str, body: str) -> str:
"""Send an email. SIDE EFFECT — pauses for human approval before sending."""
decision = interrupt({ # <- PAUSE the graph; show the human the args
"action": "send_message", "to": to, "body": body,
})
if decision.get("approve") is not True:
return "Cancelled by human — message not sent."
# ... actually send the email here ...
return f"Sent to {to}."
When the agent hits interrupt, invoke returns with the pending action; your app shows it to a human, then resumes with their decision (Command(resume={"approve": True})). The human is the last line of defense — they scrutinize the exact arguments (the model can hallucinate the recipient or the content) and approve only what's right. Reserve interrupts for the actions that truly warrant it — irreversible or high-blast-radius — so the agent stays fast on safe, read-only work. (Try it in the lab: ask the agent to email something and watch the approval gate.)
Short-Term Memory — the Checkpointer
Without memory, your agent forgets everything between turns. Short-term memory is the checkpointer: it persists the agent's state (the message history) for one conversation, keyed by a thread_id. Enable it and the agent remembers what was said earlier in the same thread — and can be paused and resumed (which is what makes the approval interrupt work):
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt import create_react_agent
checkpointer = InMemorySaver() # dev; production -> PostgresSaver / RedisSaver
agent = create_react_agent(llm, tools, prompt=SYSTEM, checkpointer=checkpointer)
cfg = {"configurable": {"thread_id": "u-123"}}
agent.invoke({"messages": [("user", "My refund didn't arrive.")]}, cfg)
agent.invoke({"messages": [("user", "How long does it usually take?")]}, cfg)
# ^ same thread_id -> the agent remembers the first turn (it's about refunds)
The checkpointer is infrastructure — turn it on for any multi-turn agent and move on. Swap InMemorySaver for PostgresSaver in production (so memory survives restarts). One rule to internalize: memory is scoped to the thread_id. Start a new thread and this conversation is gone — which is exactly right for short-term memory, and exactly why you also need a store.
Long-Term Memory — the Store
Some things should outlive a single conversation: the user's name, their preferences, a constraint they told you weeks ago. That's the store — long-term, cross-thread memory. A memory is a namespace (a tuple that scopes it, e.g. by user), a key, and a value. The store supports put, get, and — when configured with an embedding function — semantic search (recall by meaning, not exact key):
from langgraph.store.memory import InMemoryStore
# an embedding index turns the store into a semantic memory (dev; prod -> PostgresStore)
store = InMemoryStore(index={"dims": 1024, "embed": embed_fn})
ns = ("u-123", "facts") # namespace: scope memories to this user
store.put(ns, key="pref-1", value={"text": "prefers dark-roast coffee"})
store.put(ns, key="name", value={"text": "name is Sam"})
store.search(ns, query="what coffee do they like?", limit=3) # semantic recall
# -> [Item(value={'text': 'prefers dark-roast coffee'}), ...]
The store is application design, not infrastructure — you decide what's worth remembering, how it's scoped (the namespace — usually per user), and when it expires. Don't dump every message into it; save durable facts. The semantic index is what lets the agent recall "prefers dark-roast" when the user later asks "what should I order?" — meaning, not keywords.
Saving & Recalling — Memory Tools + the Recall Step
Two flows connect the store to the agent: writing memories (the agent decides something is worth keeping) and recalling them (relevant memories appear in context before the model reasons).
Writing is a tool. Give a tool access to the store with InjectedStore (LangGraph injects it — it's not an argument the model fills):
from typing import Annotated
from langchain_core.runnables import RunnableConfig
from langgraph.prebuilt import InjectedStore
from langgraph.store.base import BaseStore
@tool
def save_memory(fact: str, *, config: RunnableConfig,
store: Annotated[BaseStore, InjectedStore]) -> str:
"""Save a durable fact about the user (a preference, name, or constraint) to remember
across future sessions. Use when the user shares something worth keeping."""
user_id = config["configurable"]["user_id"]
store.put((user_id, "facts"), key=str(uuid4()), value={"text": fact})
return "Saved to long-term memory."
Recalling happens before the model reasons, via a pre_model_hook: semantically search the store for the current message and inject the matches as a system message, so the model answers with the user's history in view:
from langchain_core.messages import SystemMessage
def recall_memories(state, *, store: BaseStore, config: RunnableConfig):
user_id = config["configurable"]["user_id"]
last = state["messages"][-1].content
items = store.search((user_id, "facts"), query=last, limit=3) # semantic
if not items:
return state
recalled = "\n".join(f"- {it.value['text']}" for it in items)
note = SystemMessage(f"What you know about this user:\n{recalled}")
return {"llm_input_messages": [note, *state["messages"]]} # injected for THIS call
That pre_model_hook is the agent's "remembering" — it's why, in the lab, the agent recalls your preference even in a brand-new thread: the conversation (checkpointer) is gone, but the store is cross-thread, and recall_memories pulls the fact back into context.
Wiring It All Together
Assemble the full agent — toolset, error handling, both memories, and the recall hook — in one create_react_agent:
# src/agent/graph.py
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.prebuilt import create_react_agent
from langgraph.store.memory import InMemoryStore
from agent.tools import search_docs, web_search, run_code, send_message, save_memory
store = InMemoryStore(index={"dims": 1024, "embed": embed_fn})
agent = create_react_agent(
model=llm, # Claude Sonnet 4.6 (claude-sonnet-4-6, from L6)
tools=[search_docs, web_search, run_code, send_message, save_memory],
prompt=SYSTEM,
checkpointer=InMemorySaver(), # short-term: the conversation
store=store, # long-term: durable facts
pre_model_hook=recall_memories, # inject recalled memories each turn
)
cfg = {"configurable": {"thread_id": "t-1", "user_id": "u-123"},
"recursion_limit": 20} # + the L6 step-cap guardrail
agent.invoke({"messages": [("user", "I prefer dark roast. What should I order?")]}, cfg)
That's a capable agent: it reasons, calls the right tool (recovering from failures), pauses for approval on side effects, remembers the conversation, and recalls durable facts across sessions. The tools are still plain Python functions, though — next, L8 exposes them as MCP servers so they're portable, isolated, and reusable.
✅ Definition of Done (this step)
Before L8, your agent should have real hands and a memory:
- A toolset —
search_docs(wrapping Project 1'sretrieve),web_search,run_code,send_message— each with a clear description and a validated schema. - Tool error handling —
ToolNode(handle_tool_errors=...); a failing tool returns aToolMessageand the agent recovers instead of crashing. - Human approval on
send_messageviainterrupt— it pauses and shows the args before sending. - Short-term memory — a checkpointer +
thread_id; the agent remembers within a conversation. - Long-term memory — a store (semantic index) + a
save_memorytool and arecall_memoriespre_model_hook; facts survive across threads. - A smoke test: tell the agent a preference, start a new thread, and confirm it recalls the preference.
If your agent remembers you across a new thread and refuses to send an email without approval, you've built the hard parts. Next, L8 makes the tools portable over MCP.
See It — The Agent Memory Lab
This lab is your agent's tools + memory, made playable. Chat with it (type, or use the presets) and watch the two things that matter light up: the tools that fire and the two memory stores that fill.
- Tell it "I prefer dark-roast coffee" → it calls
save_memory, and the fact appears in LONG-TERM (store). - Ask "what's our refund policy?" → it calls
search_docs(your Project 1 retriever) and answers, grounded. - The payoff: hit New thread (the SHORT-TERM checkpointer wipes), then ask "what do I prefer?" → it recalls from the store — even though this is a new thread. That's the checkpointer-vs-store distinction, felt.
- Ask it to "email the policy to my manager" → the write tool pauses at a human-approval gate showing the exact arguments — Approve or Reject.

Notice three things. One: the checkpointer holds the conversation (thread-scoped, gone on a new thread); the store holds durable facts (cross-thread, recalled by meaning) — two memories, two jobs. Two: recall happens before the model reasons — memories are injected into context, not magically known. Three: the write tool always stops for a human — autonomy on reads, supervision on writes.
🧪 Try It Yourself
Reason these out, then check against the lab and the code.
1. Your agent answered a question correctly but called web_search when the answer was in the internal docs (it should've used search_docs). The code is fine. What's the most likely fix?
2. A user tells the agent "I'm allergic to peanuts" in one session. Three weeks later, in a new conversation, they ask for a recipe. Which memory mechanism must hold the allergy, and which step brings it back into the model's view?
3. search_docs raises a timeout. What should happen, and why is letting the exception propagate the wrong design?
4. Why does the save_memory tool take the store via InjectedStore instead of as a normal parameter the model fills in?
5. You put the user's allergy in the checkpointer (the conversation state) instead of the store. What breaks, and when?
Answers.
1. Improve the tool descriptions. The model picks tools by reading their descriptions — it's prompt engineering. Make search_docs say "use FIRST for any company/policy/product fact" and web_search say "only for public, time-sensitive info NOT in the docs." Clear, contrasting descriptions steer the choice; the fix is in the words, not the code.
2. The store (long-term, cross-thread) must hold the allergy — the checkpointer is gone once the new conversation starts. The recall_memories pre_model_hook brings it back: it semantically searches the store for the current message ("recipe") and injects the allergy as a system message before the model reasons. (This is the safety-critical case — exactly why long-term memory matters.)
3. The error should be caught and returned to the model as a ToolMessage (via handle_tool_errors) so the agent can recover — retry with a broader query, try web_search, or tell the user. Letting it propagate crashes the whole run over one flaky call; a good agent adapts to a failed tool the way a human adapts to a 404.
4. Because the store is infrastructure the runtime provides, not information the model should supply. InjectedStore tells LangGraph to inject the store at call time, hidden from the model's tool schema — so the model only fills the meaningful argument (fact), and can't be confused by (or hallucinate) a store handle.
5. The allergy vanishes the moment a new conversation (thread) starts — the checkpointer is thread-scoped. The agent would recommend a peanut recipe weeks later because it forgot. Durable, cross-session facts belong in the store; the checkpointer is only for this conversation.
Mental-Model Corrections
- "A tool is just a function." → It's a function plus a description and a schema the model reads. The description is prompt engineering — it decides whether the model uses the tool correctly.
- "More tools make a better agent." → More tools = more wrong choices. A small, clearly-described toolset beats a big ambiguous one.
- "A tool error should stop the agent." → A tool error is information for the model — return it as a
ToolMessageso the agent recovers. Crashing on one flaky call is fragile. - "All tools are equal." → Read tools are safe to call freely; write tools change the world and need human approval (an interrupt). The read/write line is the safety line.
- "Memory is one thing." → It's two: the checkpointer (short-term, thread-scoped, infra) and the store (long-term, cross-thread, design). Put the conversation in one, durable facts in the other.
- "Long-term memory just works." → Memory is written (a
save_memorytool) and recalled (apre_model_hookthat injects matches into context). If you don't inject it before the model reasons, the model doesn't know it. - "Store everything in memory." → Save durable facts, not every message. The store is design: decide what's worth keeping, scope it by user (namespace), and expire what's stale.
- "The model fills every tool argument." → Use
InjectedStore(and injected config) for runtime handles the model shouldn't see — it fills only the meaningful args.
Key Takeaways
- Tools are how the agent acts; memory is how it stays coherent. This lesson built both on the course stack (LangGraph + Claude).
- A good tool = a clear
@tooldescription + a validatedargs_schema+ token-efficient returns. The description is prompt engineering — it steers tool choice.search_docsreuses Project 1'sretrieve. - Tool errors return to the model, not the user.
ToolNode(handle_tool_errors=...)turns an exception into aToolMessagethe agent recovers from;RetryPolicyhandles transient failures. - Read vs. write is the safety line. Read tools call freely; write tools pause for human approval via an
interrupt(the L6 guardrail, realized). - Memory is two mechanisms: the checkpointer (short-term, per
thread_id, infra) holds the conversation; the store (long-term, cross-thread, semantic) holds durable facts. - Memory must be written and recalled: a
save_memorytool (viaInjectedStore) writes; arecall_memoriespre_model_hooksemantically searches the store and injects matches before the model reasons — which is why the agent remembers you across a new thread. - Next — L8 (MCP Server Integration): take this toolset and expose it over the Model Context Protocol with FastMCP — making your tools portable, isolated, and reusable by any agent, and plugging into the wider MCP ecosystem.