Savra

Why Your LangGraph Agent Loses Its Memory

Pydantic AI tool calls inside a LangGraph node do not emit to astream_events. Capture state from the node's return value via on_chain_end instead, in about ten lines.

Kash · FounderUpdated Aug 27, 20263 min read
Poster: "Your agent forgets" over a soft pastel sky and a "State persistence" stamp (Why Your LangGraph Agent Loses Its Memory)
On this page

We shipped a skills system that loads tools on demand, where activating a capability was supposed to stick. Activate documents on one turn, ask for a PDF on the next, get a PDF.

What actually happened:

1User: "Activate the documents skill"
2Agent: Skill activated
3User: "Create a PDF report"
4Agent: [should use the PDF tools, but has no memory of the skill]

Every turn started from scratch. The debug logs showed the skill being tracked correctly inside the node. The API layer received this:

1Stream complete. Activated skills for persistence: []

Tracked, and then gone. Four hours later we understood why, and the fix was about ten lines.

The trap: listening for the tool call

The obvious approach is to watch the event stream for the activation and record it:

1async for event in orchestrator.astream_events(initial_state, version="v2"):
2 kind = event.get("kind")
3
4 if kind == "on_tool_start":
5 tool_name = event.get("name")
6 if tool_name == "activate_skill":
7 # Capture the skill name from the input
8 skill = event["data"]["input"].get("skill_name")
9 activated_skills.add(skill) # never fires

This code is correct in every respect except that the event never arrives.

LangGraph's astream_events emits at the graph level. Our agent runs inside a node, and its tool calls happen within that node's execution. Those calls are real, they appear in the trace, and they are never emitted to the parent stream. The on_tool_start events you do see belong to LangGraph-native tools, which is exactly why the handler looks right and does nothing.

The trace makes the boundary visible. Everything below the node is invisible to the graph's event stream:

1unified run 5.99s $0.013877
2└── unified run 5.99s $0.013877
3 ├── chat claude-haiku-4-5 0.97s 2,368 → 85 $0.002793
4 ├── running 1 tool
5 │ └── activate_skill ← happens here
6 ├── chat claude-haiku-4-5 3.48s 4,192 → 325 $0.005817
7 ├── running 1 tool
8 │ └── create_pdf_report 0.03s
9 └── chat claude-haiku-4-5 1.50s 4,632 → 127 $0.005267

activate_skill runs. It costs time and tokens. The graph above it never hears about it.

The fix: return the state, do not intercept it

Stop trying to catch the tool call and read the node's return value instead, which on_chain_end hands you in full:

1async for event in orchestrator.astream_events(initial_state, version="v2"):
2 kind = event.get("kind")
3 name = event.get("name", "")
4
5 if kind == "on_chain_end" and name == "unified":
6 output = event.get("data", {}).get("output", {})
7
8 # Extract activated_skills from the node's returned context
9 if "context" in output and "activated_skills" in output["context"]:
10 skills = output["context"]["activated_skills"]
11 for skill in skills:
12 activated_skills.add(skill)

Which means the node has to put it there:

1async def unified_agent_node(state):
2 # ... agent execution ...
3
4 return {
5 "messages": [...],
6 "context": {
7 **state.get("context", {}),
8 "activated_skills": deps.activated_skills, # include in the return
9 },
10 }

That is the whole pattern. Anything the outside world needs to know about goes in the return value, because the return value crosses the boundary and the internal events do not.

After the change:

1SKILLS LOADED: ['documents'] ← loaded from the DB
2Skills from agent context: ['documents'] ← captured from node output
3Stream complete. Activated skills for persistence: ['documents']

Metric

Before

After

Skills persisted across turns

0%

100%

Debugging time

4 hours

0, once the pattern is known

Extra API calls for state

1 per turn

0

What this generalises to

The specific products matter less than the shape. Any time one framework runs another inside a unit of its own execution, the inner framework's events stay inside. LangChain agents in LangGraph nodes, custom tool executors, nested agent architectures of any kind: the outer stream sees the node, not what happened in it.

Two rules fall out of that.

Design the return value as your interface. Treat the node boundary like a network boundary. If a caller needs to know something, it goes in the response, not in a side channel you hope leaks through.

Test more than one turn. A single-request test passes this bug with flying colours, because everything works correctly right up until the moment it needs to survive. Anything meant to persist across a conversation needs a test that has a conversation.

That second rule is why we found it at all, and it is the cheapest thing on this list to adopt. It is also how we caught the retrieval failure that only shows up on follow-up questions, which is invisible to any test that asks one thing and stops.

FAQ

Why does on_tool_start never fire for my agent's tools?
astream_events emits at the graph level, and an inner framework's tool calls happen inside a node's execution. They never surface as graph events.

Where should you capture state from instead?
The node's return value, via on_chain_end. Structure the return to include everything downstream needs.

Does this apply outside LangGraph and Pydantic AI?
Yes, to any nested agent architecture. The boundary is between frameworks, not between these two products.

How do you catch this class of bug before users do?
Test multi-turn conversations. A single-request test passes cleanly and tells you nothing.

Keep reading

See if your brand sounds like itself.

Run the free 90-second Brand Genome audit. No card, just your score.