Agent State Persistence: How to Keep AI Agents Remembering Across Sessions
Agent state persistence is the practice of saving an AI agent's working memory, task progress, and conversation context to durable storage so the agent can resume exactly where it left off after a restart, a crash, or a new session. Without it, every agent is a goldfish: close the terminal, redeploy the container, or hit a rate limit that kills the process, and the agent's understanding of what it was doing evaporates. This matters more as agents move from single-shot chat completions to long-running, multi-step workflows that can take minutes or hours to finish.
This article covers what actually needs to be persisted, the storage patterns that work in production, how to structure state so it survives model or prompt changes, and the failure modes that catch teams off guard. Code examples are in TypeScript and Python since those are the dominant languages for agent frameworks, but the patterns apply regardless of stack.
Why agent state persistence is different from normal app state
A typical web app persists rows: a user, an order, a comment. Agent state is messier because it mixes several different kinds of information that each need different treatment:
- Conversation history: the raw sequence of messages between user, agent, and tools.
- Task state: where the agent is in a multi-step plan (step 3 of 7, waiting on tool result X).
- Working memory: facts the agent has derived or fetched that it should not have to re-derive (a parsed schema, a computed diff, a search result).
- Tool call state: in-flight or pending tool invocations, especially ones with side effects that must not be retried blindly.
- Long-term memory: facts that should outlive a single session entirely, like user preferences or prior decisions.
Treating all five as one undifferentiated "state blob" is the single biggest mistake in agent state persistence. Conversation history grows unbounded and needs pruning or summarization. Task state needs to be queryable so you can build a dashboard of "what is this agent doing right now." Tool call state needs idempotency keys so a resumed agent does not double-charge a credit card or send a duplicate email. Long-term memory needs to be separated from session state so it does not get wiped when a session ends.
The minimum viable persistence layer
Before reaching for a vector database or a custom event store, most agents only need three things: a session record, an append-only event log, and a snapshot of derived state. Here is a schema that works for the majority of agents built on top of Postgres:
create table agent_sessions (
id uuid primary key default gen_random_uuid(),
agent_type text not null,
status text not null default 'running',
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table agent_events (
id bigserial primary key,
session_id uuid not null references agent_sessions(id),
seq int not null,
kind text not null,
payload jsonb not null,
created_at timestamptz not null default now(),
unique(session_id, seq)
);
create table agent_state_snapshots (
session_id uuid primary key references agent_sessions(id),
state jsonb not null,
updated_at timestamptz not null default now()
);The event log is the source of truth. Every message, tool call, and tool result is an event with a monotonically increasing seq inside its session. The snapshot table is a cache: a materialized view of "what does the agent currently believe" that you can rebuild from the event log at any time. This separation matters because it lets you replay events to debug what happened, and it lets you rebuild the snapshot if you change how derived state is computed without losing history.
Loading an agent at the start of a session becomes a two-step read:
async function loadAgentState(sessionId: string) {
const snapshot = await db.query(
`select state from agent_state_snapshots where session_id = $1`,
[sessionId]
);
const recentEvents = await db.query(
`select kind, payload, seq from agent_events
where session_id = $1
order by seq desc limit 50`,
[sessionId]
);
return {
state: snapshot.rows[0]?.state ?? defaultState(),
recentEvents: recentEvents.rows.reverse(),
};
}Writing is append-only for events, upsert for the snapshot:
async function appendEvent(sessionId: string, kind: string, payload: unknown) {
await db.query(
`insert into agent_events (session_id, seq, kind, payload)
values ($1, (select coalesce(max(seq), 0) + 1 from agent_events where session_id = $1), $2, $3)`,
[sessionId, kind, payload]
);
}
async function saveSnapshot(sessionId: string, state: unknown) {
await db.query(
`insert into agent_state_snapshots (session_id, state, updated_at)
values ($1, $2, now())
on conflict (session_id) do update set state = excluded.state, updated_at = now()`,
[sessionId, state]
);
}This gets a surprisingly large fraction of agents to production-grade persistence without any exotic infrastructure.
Managing conversation history that outgrows the context window
Conversation history is the part of agent state that grows fastest and hurts you the most if mishandled. Two failure modes show up constantly: replaying the entire history on every turn until it blows the context window, and summarizing too aggressively so the agent loses details it needs three steps later.
The pattern that holds up well is a rolling window plus periodic summarization, kept in the same event log:
def build_context(session_id: str, max_tokens: int = 8000) -> list[dict]:
events = fetch_events(session_id)
summary = fetch_latest_summary(session_id)
context = []
if summary:
context.append({"role": "system", "content": f"Prior context summary: {summary}"})
running_tokens = count_tokens(context)
tail = []
for event in reversed(events):
event_tokens = count_tokens(event["payload"])
if running_tokens + event_tokens > max_tokens:
break
tail.insert(0, to_message(event))
running_tokens += event_tokens
return context + tailWhen the tail is about to be dropped, run a summarization pass that folds the oldest events into an updated running summary and store that summary as its own event kind (summary_updated). Do not summarize into the snapshot table directly, because if the summarization prompt or model changes, you want to be able to regenerate summaries from raw events rather than being stuck with a summary you can no longer reproduce.
A detail that trips people up: summarize task-relevant facts, not conversational flavor. "User asked to refactor the auth module; agent identified 3 files touching JWT validation; agent has modified 1 of 3" survives compression far better than a paraphrase of the back-and-forth.
Handling in-flight tool calls safely
The riskiest moment for agent state persistence is a crash or restart while a tool call is in flight. If the agent called a payment API, sent an email, or triggered a deployment, and the process dies before it records the result, a naive resume will retry the call and cause a duplicate side effect.
The fix is to persist tool call intent before execution, with an idempotency key, and only mark it complete after the result is durably stored:
async function executeTool(sessionId: string, toolCall: ToolCall) {
const idempotencyKey = `${sessionId}:${toolCall.id}`;
await appendEvent(sessionId, "tool_call_started", {
toolCall,
idempotencyKey,
});
const existing = await checkIdempotencyLedger(idempotencyKey);
if (existing) {
await appendEvent(sessionId, "tool_call_completed", existing);
return existing;
}
const result = await callTool(toolCall, { idempotencyKey });
await recordIdempotencyLedger(idempotencyKey, result);
await appendEvent(sessionId, "tool_call_completed", { toolCall, result });
return result;
}On resume, check the event log for any tool_call_started event without a matching tool_call_completed. Do not blindly re-run it. Either check the idempotency ledger (if the downstream API supports idempotency keys, like Stripe does), or, if it does not, treat the call as ambiguous and surface it to a human or a reconciliation step rather than guessing. This one pattern prevents most of the embarrassing incidents that come from naive agent resumption.
Separating session state from long-term memory
A common design mistake is storing user preferences, learned facts, or cross-session context in the same table as session-scoped conversation state. When a session ends or gets archived, that long-term information disappears with it.
Keep long-term memory in its own store, keyed by a stable identity (user id, workspace id, or agent instance id) rather than session id:
create table agent_memory (
id uuid primary key default gen_random_uuid(),
owner_id text not null,
memory_type text not null,
content text not null,
embedding vector(1536),
created_at timestamptz not null default now(),
source_session_id uuid
);The source_session_id column is a pointer back to where the memory came from, useful for auditing why the agent believes something, but the memory itself is not deleted when that session is. When a new session starts, pull relevant long-term memories with a similarity search or a tag-based lookup and inject them into the system prompt or an early context message, separate from the rolling conversation window described above.
This separation also gives you a clean answer to "how do I let the user edit or delete what the agent remembers about them" without touching session data at all, which matters for privacy requests.
Framework-level persistence: what to check before you build your own
Several agent frameworks ship persistence primitives out of the box, and it is worth checking whether one covers your case before building the schema above from scratch:
- LangGraph has a checkpointer abstraction that serializes graph state after each node execution, with built-in backends for Postgres, SQLite, and Redis. It is a good fit if you are already using LangGraph for orchestration, since checkpoints line up with your graph's step boundaries.
- Claude Agent SDK and similar SDKs that expose an agent loop typically let you pass in a persisted message history and resume the loop from it; the persistence mechanics (where and how you store that history) are left to you, which maps well onto the event-log pattern above.
- Temporal and similar durable-execution engines treat the entire agent run as a workflow, persisting execution state at every step automatically. This is heavier infrastructure but removes almost all of the manual bookkeeping described in this article, at the cost of adopting a workflow engine.
The tradeoff is control versus convenience. Framework checkpointers get you moving fast but often serialize an opaque blob that is hard to query, migrate, or debug outside the framework. A hand-rolled event log is more work upfront but gives you a durable, inspectable audit trail that survives framework churn, which matters a lot once you have agents running in production for months.
Versioning state so prompt and schema changes do not break resumption
Agent state persistence has a problem that normal app state mostly avoids: the "schema" of what the agent needs to function includes the system prompt, the tool definitions, and sometimes the model itself. If you change any of those between when a session was created and when it resumes, old state can become invalid or misleading.
Store a version tag with every session and snapshot:
await saveSnapshot(sessionId, {
version: "agent-v14",
toolsetVersion: "tools-2026-06",
state: derivedState,
});On resume, check the version against the current agent definition. If they differ, decide explicitly rather than silently trusting old state: migrate the state with a written migration function, or fall back to reconstructing state from the raw event log using the current logic, or in the worst case flag the session for a human to review. Silently loading v10 state into v14 agent logic is how you get agents that behave in subtly wrong ways that are very hard to reproduce, because the bug only shows up on resumed sessions, not fresh ones.
Testing agent state persistence like you mean it
Persistence bugs in agents are notoriously hard to catch in normal testing because the happy path (agent runs start to finish in one process) never exercises the resume path at all. Build resume testing into your test suite deliberately:
def test_agent_resumes_after_simulated_crash():
session_id = start_agent_session(task="refactor auth module")
run_agent_steps(session_id, count=3)
# simulate crash: drop any in-memory state, reload from storage only
state = loadAgentState(session_id)
assert state["recentEvents"], "event log must not be empty after 3 steps"
resumed_result = run_agent_steps(session_id, count=1, from_persisted=state)
assert resumed_result.status != "duplicate_tool_call"Also test the tool-call-interrupted case explicitly: kill the process (or simulate it) between tool_call_started and tool_call_completed, then resume and assert the tool is not called twice. This is the single most valuable test in an agent persistence suite because it is the failure mode most likely to cause real damage in production.
FAQ
Do I need a vector database for agent state persistence? No. A vector database helps with semantic search over long-term memory, but session state, task progress, and tool call state are relational and time-ordered, which a standard SQL database handles well. Reach for vector search only when you have a specific retrieval problem, like finding relevant past memories among thousands of entries.
Should I persist the full raw conversation or a compressed version? Persist the full raw event log as your source of truth, and derive compressed context (summaries, snapshots) on top of it. Storage is cheap; being unable to reconstruct what actually happened is expensive when you are debugging a production incident.
How often should I write a snapshot versus relying on the event log? Write a snapshot whenever the agent reaches a stable checkpoint, such as after completing a step in a plan, not after every single token or tool call. Snapshotting too frequently adds write load for little benefit, since the event log already gives you point-in-time recovery.
What is the difference between agent state persistence and conversation memory features in chat apps? Chat app "memory" features usually persist a small set of user facts across conversations (name, preferences). Agent state persistence is broader: it covers task progress, tool call state, and working memory needed to resume a specific, possibly long-running, task exactly where it stopped.
Can I persist agent state in Redis instead of Postgres? You can, and Redis is a reasonable choice for the snapshot layer if you need very low read latency, but be careful using it as your only store for the event log. Redis persistence models (RDB snapshots, AOF) are weaker durability guarantees than a proper database, and you lose easy ad hoc querying for debugging. A common pattern is Redis for hot snapshot reads with Postgres as the durable event log underneath.
How do I handle state persistence for multi-agent systems where several agents share a task? Give each agent its own session and event log, but link them through a shared parent task id. Persist cross-agent messages as events in both the sending and receiving agent's logs, and keep a separate task-level table that tracks overall status independent of any single agent's internal state. This avoids the tangle of trying to serialize multiple agents' state into one blob.
Does agent state persistence slow down response latency? A well-indexed event append and periodic snapshot write add single-digit milliseconds, which is negligible next to typical LLM inference latency. The place teams actually see slowdown is loading a large, unpruned conversation history on every turn, which is a context-window management problem, not a persistence-layer problem, and is fixed by the rolling window and summarization pattern described above.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.