Agentic AI · LangGraph Persistence

Persistent Agents & Context Management

How to give a support agent memory that survives crashes and multi-day cases — and then stop that same memory from quietly bankrupting you and wrecking its reasoning.

Updated September 2026 · Read ~17 min · Source: live session (state persistence & context management)


// the big idea

An agent's memory is an architecture problem with two opposite failure modes. Too little memory and it forgets the customer the moment the server restarts. Too much and its context bloats — cost climbs linearly, reasoning degrades, and it eventually overflows the window and crashes. Persistence fixes the first; a deliberate three-layer context strategy fixes the second. Neither is optional in production.

Section 1Notebook Summary

Hand-copyable revision notes. Every line here is derivable from the Detailed Notes below.

1 · Time Travel & Rewind

  • State history = full log of every checkpoint. Not just debugging → in regulated domains it's a compliance requirement.
  • Rewind workflow: enumerate history → find checkpoint before error → load → (opt.) fix state → resume.
  • Only nodes after the checkpoint re-run. Correct earlier LLM calls are not repeated → saves cost.
  • Ex: pricing bug quotes wrong price → reload pre-tool checkpoint, swap bad tool output, resume.
  • ✎ doodle: filmstrip of boxes; arrow curls back from box 5 → box 3 = rewind to checkpoint.

2 · Human-in-the-Loop (HITL)

  • Time travel = infrastructure for HITL, not just debug. 5 uses:
Use What
Compliance route high-risk decisions to human before execute
QA sample responses, correct post-run
A/B test fork at checkpoint, 2 prompts, compare
Training data correct checkpoints = ground truth
Incident restore old state for forensics

3 · Interrupt / Resume

  • interrupt_before=[node] at compile → pause before node, return control.
  • Pause = a checkpoint → survives server restart. Human resumes hours later w/ same thread_id.
  • Beats old HITL (keep process alive = fragile; or custom queue). Collapses into existing checkpoint infra.

4 · Persistent Agent (hands-on)

  • SqliteSaver checkpointer → writes all state to local support.db (+ -wal, -shm).
  • thread_id → isolates conversations; many users concurrently.
  • Graph unchanged (classifier→agent⇄tool→respond). Only add: checkpointer @ compile + thread config @ invoke.
  • Follow-up turn on same thread → reuses prior tool result, skips re-calling the tool.

5 · The Context Time Bomb

  • Every turn appends msgs (AI + tool). State grows linearly. 3-step loop × 50 turns ≈ 150 msgs.
  • Whole history sent to LLM every call → cost ∝ length. Turn 50 ≈ 100× turn 1.
  • Fails 3 ways: cost ↑, reasoning ↓, eventually window overflow → crash.
  • Last session solved no-memory; this one solves too-much-memory.
  • ✎ doodle: fat pipe narrowing to a straw = attention spread thin as tokens grow.

6 · Why bigger windows ≠ fix

  • Attention dilution: fixed budget (=1) split across N tokens. More tokens → less weight each.
  • Needle-in-haystack: ~90–95% recall at 10Ks of tokens → ~60–70% at 100K. Middle gets buried.
  • Reasoning chain degrades: small mis-weights compound → confidently wrong.
  • Rule: quality of reasoning = f(quality of context), NOT f(how much context fits).

7 · Three-Layer Framework

Layer Action On
1 Verbatim keep word-for-word recent 3–5 turns
2 Compress semantic summary older high-meaning
3 Discard drop pleasantries, ack, noise
✎ doodle: 3 stacked trays — top "keep", middle "squash", bottom "bin".

8 · Pruning vs Summarization

  • Sliding window: keep last N. Simple, cheap. Small N cuts cost hard.
  • Anything past window = gone forever, silently. OK for FAQ (independent Qs), NOT for support.
  • Summarization: compress older msgs into dense summary in a state field, inject into prompt.
  • Lossless vs lossy (PNG vs JPEG). Summary is lossy: keep facts/IDs/decisions, drop phrasing/tone.

9 · Summarization Node

  • Separation of concerns: agent node = reasoning ONLY. Summarize in a dedicated node.
  • Entangling both → a summarizer bug (drops account ID) looks like a reasoning/tool bug. Hard to debug.
  • Isolated node = unit-testable, swappable (flash→pro) without touching agent node.

10 · Conditional Routing

  • Don't summarize every turn (kills the savings). Gate on message count > threshold (tunable, kept external).
  • Conditional edge: count>thr → summarize; last msg has tool_calls → tools; else → END.
  • Both summarize & tools loop back to agent. Summarize = maintenance, not terminal.

Boxed markers

⭐Rewind only re-runs nodes after the checkpoint — the correct steps' expensive LLM calls are reused, not repeated.
⚠Sliding-window failures are silent: the agent won't flag that dropped info ever existed — it just answers confidently wrong.
⚠Putting summarization inside the agent node entangles two jobs; a memory bug then masquerades as a reasoning bug.
💡Context management is framed as cost, but its real job is reliability — an unmanaged agent is a prototype with a deferred crash.
💡Interrupts persist as checkpoints, so a paused review survives a restart — far more robust than keeping a process alive.

Section 2Detailed Study Notes

A customer opens a chat on Monday about a double charge. They come back Thursday to finish it. If your agent forgot everything the instant the server restarted, they have to explain the whole thing again — and they will not be gracious about it. So you give the agent memory: every message, every tool result, written to disk. It works. Then, fifty turns into a knotty case, that same agent starts quoting the wrong invoice number, and the bill for a single reply quietly costs a hundred times what the first one did. Memory solved one problem and created its mirror image. This lesson is about handling both ends.

2.1Why an agent needs memory at all

Persistence is giving the agent a place to write down what happened so it isn't lost when the program stops. A plain chat agent holds the conversation in RAM. Kill the process — a deploy, a crash, an autoscaler reclaiming the box — and the entire conversation evaporates. For a throwaway demo that's fine. For a support product where one case spans days, it's fatal.

Analogy · recall

Think of the difference between a cashier who remembers your order only while you're standing there, and one who writes your ticket on a rail. Shift change, lunch break, power cut — the ticket is still on the rail. Persistence is the rail.

The mechanism is the checkpoint: after each step of the graph, the agent's full state is serialized and saved. A checkpointer is the component that does the saving. In the last session the state lived only in memory (a MemorySaver); here we swap in one backed by a real database so it outlives the process.

2.2Time travel: state history and rewind

Because every step is checkpointed, the whole conversation is a list of saved states you can walk back through — that's "time travel." The naive read is that this is a debugging convenience. The sharper read, and the one the instructor stressed, is that in regulated industries a system that makes consequential decisions without a history of those decisions is non-compliant by design. The audit trail isn't a nicety; it's the point.

The practical payoff is rewind: correcting a mistake surgically instead of restarting the conversation.

Enumerate history→ Find checkpoint before error→ Load it→ Modify state (fix)→ Resume
classify agent load here tool ✗ respond rewind bad tool output reload · swap value · resume from here
fig 2.2 — rewind reloads the checkpoint before the fault; only the nodes to its right re-run.

The worked example: a pricing tool returns a wrong price because of a data bug (the instructor's real-world echo — a ₹25,000 item briefly listed at ₹250). The agent quotes the bad number to the customer. Once the service bug is fixed, you don't apologize and restart. You reload the checkpoint just before the pricing call, replace the bad tool output with the right one, and resume. Everything before that point — already correct — is reused as-is.

⭐ trick

Resuming from a checkpoint re-executes only the nodes after it. The earlier steps, including their expensive LLM calls, are not re-run — you pay to regenerate just the part that was actually wrong. Error handling flips from "sorry, let's start over" to detect → rewind → correct → continue.

2.3Human-in-the-loop: five things time travel unlocks

Once you can pause, inspect, fork, and restore states, a human can be inserted anywhere in the loop. "Human-in-the-loop" (HITL) just means a person reviews or corrects the agent at some point. Time travel is the infrastructure that makes all five of these cheap:

compliance review

Route high-risk decisions to a human before they execute.

quality assurance

Sample responses for human review and correct them after the run.

A/B testing

Fork a conversation at a checkpoint, run two prompts, compare outcomes.

training data

Treat human-approved checkpoints as ground-truth examples for fine-tuning.

incident response

Restore any conversation to a past state for forensic investigation.

💡 tip

The two-response "which do you prefer?" pattern you've seen in ChatGPT is exactly the A/B fork: same checkpoint, two backend prompts, and the human's pick is the signal. Being able to name this in an interview shows you understand forking, not just prompting.

2.4Interrupt and resume mechanics

LangGraph has a formal pause button: compile the graph with interrupt_before (or interrupt_after) a node, and execution stops there and hands control back. The current state is exposed for inspection and editing. When the reviewer is done, you call invoke again with the same thread_id; the state updates and the graph resumes from where it paused.

running… interrupt_before pause + checkpoint saved to db server can restart here human edits state resume same thread_id · hours later
fig 2.4 — the pause is stored as a checkpoint, so it outlives a restart.

The reason this is robust: the pause is a checkpoint with a "waiting for human" flag. If the server restarts while the graph is parked, the pause is still on disk — the human can return hours later, load the state, make changes, and continue. Compare that to traditional HITL, which either keeps the process alive for the whole review window (expensive and fragile) or bolts on a custom queue to route the request out to a human and the answer back. LangGraph collapses that complexity into the checkpoint infrastructure you already have.

2.5Hands-on: the persistent support agent

The entire graph stays the same — the only production change is where the state is saved. The graph is still classifier → agent ⇄ tool → respond with conditional edges between them. Two edits promote it to production-grade: a database-backed checkpointer at compile time, and a thread configuration at invoke time.

classifier→ agent⇄ tool→ agent→ respond
SqliteSaver

Persists every checkpoint to a local file — support.db (plus -wal and -shm from SQLite's write-ahead log).

thread_id isolation

Each conversation lives under its own thread id, so many users run at once without bleeding into each other.

state history

Every node's snapshot is retrievable — step, source node, category, message count, final response, checkpoint id.

crash recovery

A recovery script resumes a conversation from a simulated crash without re-running completed steps.

persist.py — the only change from the in-memory version
from langgraph.checkpoint.sqlite import SqliteSaver

# One local file holds every thread's checkpoints
checkpointer = SqliteSaver.from_conn_string("support.db")

# Same builder as before — only .compile() changes
graph = builder.compile(checkpointer=checkpointer)

# thread_id is what isolates one conversation from another
config = {"configurable": {"thread_id": "u4-s00001"}}
graph.invoke({"messages": [user_msg]}, config)

[+] The code is idiomatic LangGraph shown for precision; the lesson described these APIs (SqliteSaver, thread_id config, get_state_history, update_state) without walking every line.

The behaviour that makes it click: send a second message on the same thread and the agent already has the full prior context loaded from disk. Ask "what was the outstanding balance?" and it answers 998 directly — the node path shrinks to classify → agent → respond, with no tool call, because the CRM result from the first turn is already sitting in the checkpoint.

follow-up turn · same thread_id
# Full history is restored from support.db for this thread
config = {"configurable": {"thread_id": "u4-s00001"}}

graph.invoke(
    {"messages": [HumanMessage("what was the outstanding balance?")]},
    config,
)
# -> "Your outstanding balance is 998."
# classify -> agent -> respond   (get_customer_details is NOT called again)
💡 tip

Skipping the repeat tool call isn't just faster — it's cheaper and more consistent. The agent reads the prior tool result straight out of the checkpoint instead of paying for another CRM round-trip and risking a different answer.

2.6The time bomb: memory that only grows

The persistent agent faithfully keeps everything — and "everything" is the problem. Every tool call appends a tool message; every agent turn appends an AI message; the checkpointer serializes all of it. There's no forgetting. The architecture that fixed no-memory now grows without bound, and it detonates in production without warning.

A three-step react loop per turn (agent thinks → tool runs → agent responds) means each turn adds roughly three messages. After 10 turns that's ~30 messages; after 50 turns, ~150. A genuinely complex case spanning several days easily reaches 150+ messages — and the full list is sent to the model on every single inference call.

Turn Input tokens / call Multiplier
1 ~500–600 1×
5 ~3,000 5×
10 ~6,000 10×
20 ~12,000 20×
50 ~30,000 (≈50–60k with reasoning steps) ~100×

Figures are the lesson's worked estimates (≈200 tokens/message, 3 messages/turn), for scale — not measured benchmarks.

conversation turns → tokens / call unmanaged (∝ length) managed (bounded) gap ≈ 93% cost cut at ~100 turns
fig 2.6 — unmanaged context cost climbs with every turn; managed context stays flat.

Three failure modes ride along together: cost grows linearly, reasoning quality degrades as the context gets noisier, and eventually the history exceeds the model's window and the API errors out — the production agent crashes mid-conversation, on a live customer. Last session solved the no-memory problem; this session solves the too-much-memory problem.

💡 tip

The instructor reframes this as reliability, not thrift: an agent that's correct for 10 turns and confidently wrong at 50 isn't a cheap product — it's a prototype with a deferred failure mode, and it fails at the worst possible time.

2.7Why a bigger window is not the fix

"Just use a million-token window" is an architectural error, not a solution. A larger window is more expensive and, more importantly, doesn't make reasoning better. Two mechanisms explain why.

Attention dilution

A transformer spreads a fixed attention budget across all token pairs. As the sequence grows, the share landing on any one token shrinks. A critical fact stated at turn 2 of a 50-turn case is competing with 48 turns of subsequent noise for the model's attention.

Tokens in context Avg attention weight / token
10 0.1
100 0.01
10,000 0.0001

The needle-in-a-haystack (a.k.a. "lost in the middle") benchmark makes it concrete: hide one relevant fact at varying positions in a long document and ask the model to find it. Recall that sits around 90–95% at tens of thousands of tokens can fall to roughly 60–70% at 100,000 tokens, worst when the target is buried in the middle. Adding tokens doesn't improve retrieval — it makes it worse.

budget = 1 more tokens → each gets a thinner slice
fig 2.7 — a fixed attention budget spread across more tokens leaves less for each.

Reasoning chain degradation

Chain-of-thought reasoning is sensitive to how dense and close the relevant context is. Each hop that slightly mis-weights or mis-remembers an earlier fact compounds; over a long noisy context these errors accrete into answers that are internally consistent with the mess but wrong about reality.

Analogy · recall

A skilled consultant handed a clean two-page brief runs circles around the same consultant handed a disorganized 200-page archive and told to find the facts themselves. Same expertise; different context management. Quality of reasoning is a function of the quality of context — not how much context technically fits.

⚠ pitfall

Never let context-window size stand in for a context-management strategy. Reaching for a bigger window is the reflex that quietly caps your accuracy and multiplies your bill at the same time.

2.8The three-layer framework

The architect's job is to write down policies for how information flows through the window over time — and those are domain decisions, not something to hand the model. Every piece of history is sorted into one of three layers:

Layer 1 · VERBATIM keep word-for-word — recent 3–5 turns, every word may matter Layer 2 · COMPRESS semantic summary — older high-meaning content; keep facts, drop phrasing Layer 3 · DISCARD drop — pleasantries, acknowledgements, procedural noise
fig 2.8 — keep / squash / bin, decided by the architect in code.

Verbatim means exactly as said — for the last few turns, exact wording (account IDs, amounts, promises) matters. Semantic compression keeps the meaning of older content while throwing away the phrasing. Discard removes procedural noise that carries no bearing on future reasoning. These judgments are architectural: they depend on your domain, and they must be explicit in code.

2.9Pruning: the sliding window

The simplest strategy is to keep only the last N messages and drop the rest. N is the one design knob. A small window (4–6) slashes cost but creates a hard memory horizon: anything older is permanently gone, and the agent doesn't even know it existed. A large window (20–30) preserves more but saves less — and no value of N escapes the core limitation: once a message falls outside the window, it's irretrievable.

lost lost window = last N kept newest
fig 2.9 — the window slides forward; whatever falls off the left is gone silently.
⚠ pitfall — silent failure

At turn 2 the customer gives account C00001 and invoice I218. With a 6-message window, by turn 10 both have fallen off. Asked "what was the invoice number again?", the agent doesn't say "I lost that" — it hallucinates a plausible invoice number or goes vague. It never flags that the information existed. Confident, fluent, wrong.

So is pruning ever right? For an FAQ chatbot where each question is independent, yes — nothing before this question should influence the answer. For a support agent tracking one evolving case, absolutely not. That's the domain specificity of the choice.

2.10Summarization: lossy compression done on purpose

Instead of discarding old messages, compress them. When the message count crosses a threshold, a dedicated LLM call produces a dense summary of the older messages, stored in its own state field and injected into the agent's system prompt.

Analogy · recall

Lossless vs lossy compression. A PNG keeps every bit; a JPEG throws away high-frequency detail for a much smaller file, and for most images you can't tell. Summarization is JPEG for conversation: exact wording, tone, and the precise reasoning sequence are lost; the key facts, decisions, commitments, and customer data are kept. For support, that's the right trade.

summary prompt · the policy, written down
You are a precise summarizer for customer support.
Preserve EXACT values (account IDs, invoice numbers, amounts).
Compress; do NOT keep verbatim wording.
Delete acknowledgements and procedural back-and-forth.
Output ONE paragraph, max 5 sentences. No preamble, no labels.

2.11The summarization node & separation of concerns

Knowing how to summarize isn't enough — where the logic lives in the graph decides whether you can maintain it. The agent node has one job: pure reasoning (invoke the LLM with the bound tools and current messages). Stuff summarization inside it and you've given one node two jobs with different performance profiles, failure modes, and testing needs.

⚠ pitfall — entangled responsibilities

Say the in-node summarizer has a bug that silently drops the customer's account ID during compression. The symptom shows up as the agent calling the CRM tool with the wrong ID — it looks like a reasoning error or a tool-calling error. The root cause is memory management. Because the two jobs are tangled together, the failure surface is huge and the bug is miserable to find.

bad · entangled agent node reasoning + summarization good · isolated summarize node agent node loop
fig 2.11 — one job per node; the summarizer feeds the agent and is tested on its own.

The fix is separation of concerns: a dedicated summarization node with one job — take the message list, compress the older ones into a summary, update state, return. Well-defined input and output means it can be unit-tested (assert the summary still contains the required facts) and swapped (upgrade flash → pro) without touching — or re-testing — the critical agent node and its whole react loop.

summarize_node — one job, testable, swappable
def summarize_node(state):
    msgs = state["messages"]
    old, recent = msgs[:-VERBATIM], msgs[-VERBATIM:]     # layer 2 vs layer 1
    summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(old))
    return {"system_summary": summary.content, "messages": recent}

2.12Conditional routing: the maintenance loop

The summarization node must not run on every turn — that would erase the savings and add latency to every response. It fires only when the message count exceeds a threshold (a tunable parameter, kept outside the production DB so it can be changed freely). Routing is a LangGraph conditional edge, the same pattern used everywhere else, applied to a new decision.

route_after_agent — the conditional edge
def route_after_agent(state):
    if len(state["messages"]) > THRESHOLD:      # too big -> maintain
        return "summarize"
    if state["messages"][-1].tool_calls:        # needs a tool -> tools
        return "tools"
    return END                                  # done

Both the summarize node and the tool node always loop back to the agent node. Summarization is a maintenance operation, not a terminal state — which is exactly why it's called a maintenance node. The shape is the react loop with one extra branch, nothing more exotic.

⭐ trick

Keep the threshold as an external, executable parameter — never baked into the production database. When a case runs long, you tune the threshold to trade compression frequency against overhead, and the summarizer simply re-fires each time the count crosses the line again.

[!] The lesson quoted the react-loop circuit breaker as both 12 (the MAX_ITERATIONS constant) and "maximum 5" in passing; treat 12 as the configured cap and the smaller number as a typical per-turn count. Also: 1M-token windows were attributed once to Gemini 1.5 Pro and once to Gemini 2.5 Flash — the exact model label isn't load-bearing for the point.


2.13Interview & practice Q&A

Why is state history a compliance requirement, not just a debugging aid?

In regulated industries, an AI system that makes consequential decisions without a durable record of those decisions is non-compliant by design. The checkpoint history is the audit trail — it lets you reconstruct exactly what state produced a given decision. Debugging is a bonus; the legal defensibility is the reason it's mandatory.

During a rewind, which steps re-run and which don't?

You reload the checkpoint immediately before the fault, optionally correct the state, and resume. Only the nodes after that checkpoint re-execute. Everything earlier — already correct — is reused, including its expensive LLM calls, so you regenerate just the part that was wrong.

How does LangGraph's interrupt survive a server restart when traditional HITL can't?

The pause is saved as a checkpoint with a "waiting for human" flag, so it lives on disk. If the server restarts, the paused state is still there and a human can resume it hours later with the same thread_id. Traditional HITL instead keeps the process alive (fragile, costly) or bolts on a custom queue — LangGraph folds it into the checkpoint infrastructure you already have.

Why doesn't a bigger context window solve the memory problem?

Two reasons. Attention dilution: the model's fixed attention budget is spread across all tokens, so each token gets less weight as the sequence grows — needle-in-a-haystack tests show recall dropping from ~90% to ~60–70% as context balloons. And reasoning-chain degradation: small mis-weights compound into confidently wrong answers. Bigger windows also cost more. Quality tracks the quality of context, not its size.

Pruning vs summarization — when is each correct?

Pruning (sliding window) keeps the last N messages and discards the rest — cheap and simple, but anything older is gone silently. It's fine for an FAQ bot where each question is independent. Summarization compresses older messages into a dense summary instead of dropping them, preserving facts across a long case. For a stateful support agent, summarization is the right call.

Why must summarization live in its own node?

Separation of concerns. The agent node's single job is reasoning; adding summarization gives it two jobs with different failure modes. A summarizer bug (e.g. dropping an account ID) then masquerades as a reasoning or tool-call error and is painful to isolate. A dedicated node is unit-testable and swappable without re-testing the whole react loop.

When should the summarization node run, and how is that decided?

Only when the message count exceeds a tunable threshold — never on every turn, or you erase the savings and add latency to every response. A conditional edge routes to "summarize" when count > threshold, to "tools" when the last message has tool calls, and to END otherwise. The threshold is kept external to the production DB so it can be changed freely.

Summarization is lossy — what do you keep versus throw away?

Keep: key facts, decisions, commitments, and exact customer-specific values (account IDs, invoice numbers, amounts). Throw away: exact wording, emotional tone, the precise reasoning sequence, and procedural back-and-forth. It's the JPEG trade — meaning preserved, phrasing discarded — which is acceptable for most support use cases.

A sliding window "works" in dev but fails in production — why, and how would you catch it?

Dev testing runs short conversations that never exceed the window, so nothing is ever lost. In production a long case pushes early facts out of the window and the agent answers confidently wrong without flagging it. Catch it with long-conversation tests that assert an early-turn fact (an account ID stated at turn 2) is still answerable at turn 10+, not just short happy-path runs.


2.14Key takeaways

checkpointer thread_id isolation state history → rewind interrupt_before pause = checkpoint context time bomb attention dilution needle-in-haystack verbatim · compress · discard sliding window = silent loss lossy summary separation of concerns threshold-gated maintenance node

Section 3Microexercises

Reading proves you recognise the idea; these prove you can use it. Attempt each before opening the walkthrough.

3.1Build it — the routing edge

// your task

Write the conditional-edge function route_after_agent(state) that decides where the graph goes after the agent node.

Requirements:

starter · state shape
THRESHOLD = 12
# state["messages"]      -> list of message objects
# state["messages"][-1]  -> last message; .tool_calls is truthy when a tool is requested
# valid returns: "summarize" | "tools" | END

def route_after_agent(state):
    ...   # your code

Expected: a 20-message state returns "summarize"; a 4-message state whose last message has tool calls returns "tools"; a 4-message state with no tool calls returns END.

Show the full walkthrough

Step 1 — get the order right. Maintenance is checked first, unconditionally on size. If you checked tool calls first, a bloated conversation that also happens to request a tool would skip summarization and keep growing — the exact failure the node exists to prevent.

Step 2 — size gate. Compare len(state["messages"]) against THRESHOLD. Strictly greater than, matching "exceeds".

Step 3 — tool gate. Only reached when the state is small enough. Inspect the last message's .tool_calls; if truthy, the agent asked for a tool, so route to "tools".

Step 4 — fall through to END. No maintenance needed and no tool requested means the turn is done.

solution
from langgraph.graph import END

def route_after_agent(state):
    if len(state["messages"]) > THRESHOLD:
        return "summarize"
    if state["messages"][-1].tool_calls:
        return "tools"
    return END

Why: this single function is the whole maintenance loop — checking size before tools is what guarantees the graph summarizes before it overflows, and both "summarize" and "tools" edges loop back to the agent.

3.2Diagnose it — the entangled node

// your task

This agent node was "optimised" to manage its own context. Predict exactly what goes wrong, name the root cause, then fix it.

broken · agent_node.py
def agent_node(state):
    # summarize the history, then reason
    summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(state["messages"]))
    state["system_summary"] = summary.content
    msgs = [SystemMessage(state["system_summary"])] + state["messages"]
    return {"messages": [llm_with_tools.invoke(msgs)]}

Predict: what does this cost you per turn, and what happens the day the summarizer drops the account ID?

Show the full walkthrough

Root cause — two failures, one node. First, there's no threshold gate: the summarizer runs on every turn, so you pay for an extra LLM call and add its latency to every single response — the cost savings summarization was supposed to buy are gone. Second, reasoning and memory maintenance are entangled in one node.

Predict the failure. The day the summary prompt silently drops the customer's account ID, the agent builds msgs without it and then calls the CRM tool with a missing or wrong ID. The visible symptom is a bad tool call or a confidently wrong answer — it looks like a reasoning bug, so you'll debug the react loop for hours. The real defect is in the summarizer, in this same node. No exception is thrown; it fails as wrong output, which is worse than a crash.

Fix — separate the concerns and gate on size. Move summarization into its own node and let a conditional edge decide when it runs.

fixed · one job each
def agent_node(state):                     # reasoning ONLY
    system = [SystemMessage(state.get("system_summary", ""))]
    msgs = system + state["messages"]
    return {"messages": [llm_with_tools.invoke(msgs)]}

def summarize_node(state):                 # maintenance ONLY
    old, recent = state["messages"][:-VERBATIM], state["messages"][-VERBATIM:]
    summary = summarizer_llm.invoke(SUMMARY_PROMPT + render(old))
    return {"system_summary": summary.content, "messages": recent}

# route_after_agent (from 3.1) fires summarize_node only when count > THRESHOLD

Why: now a summarizer bug surfaces at the summarize node — unit-testable in isolation — and it only runs when the threshold is crossed, so the agent node stays a pure, cheap reasoning step.

check size before tools one job per node gate on threshold memory bug ≠ reasoning bug loop back to agent