Skip to content

Repository files navigation

langgraph-beads-memory

Beads-style durable memory for LangGraph agents on Postgres — a typed fact/conclusion graph with explicit capture (not blind auto-extraction) and enforced sub-agent memory forking with rollup summaries, instead of an opaque conversation summary.

What this is for. LangGraph's BaseStorePostgresStore with pgvector, usually with LangMem on top — stores documents and cosine-searches them back. It has no vocabulary for three things a long-running agent needs to express:

  • this value replaced that one — a corrected budget and the stale one sit in the store 0.002 apart, with nothing marking which is current
  • this claim came from that sub-agent — parallel researchers land in one flat namespace, and one investigator's exploration crowds out another's conclusion
  • this is a question, not a fact — questions rank highly against a query precisely by resembling it

The bet is that those are missing types, not retrieval quality to be tuned away. This is the implementation, and the measurements testing whether the bet pays.

What the measurements say. At a matched context budget it beats LangGraph's store: 51/78 to 42/78 on LongMemEval, winning 22 questions and losing 13. Given an unbounded window it does not — more context beats less, and the advantage decays to a tie by a 6,000-token budget.

So this is not a better search engine. It is a gatekeeper: an external retriever competes with attention and loses on material attention can already reach, so memory earns its place deciding what enters the window — and only when the window is too small to hold everything.

255 tests against real Postgres. Part 1 of a series — the write-up is Building a Fact-Driven Memory Layer for LangGraph; the round is results/2026-08-19-context-budget.md. Longer sessions (~115k tokens) and the multi-turn agent scenarios are follow-ups, named as such below.

An earlier version of this line led with "−9.8% input tokens and 6.33 of 8 objective metrics against 3.67", measured on this project's own scenarios. That figure did not survive and has been retired; every token figure recorded before 2026-08-19 was also measured with Ollama silently truncating prompts at 2048 tokens. Both are documented rather than quietly re-run — results/.

What it gives you

Three properties, each a consequence of storing memory as a graph of individual claims rather than as saved documents.

Two different evidence bases, and it matters which is which. §1 and §2 are mechanism measurements — how large the injected block is as the store grows — taken from this project's own scenario runs, where that is exactly what a designed scenario is good for. §3 is an accuracy claim and is measured on an external benchmark, because a designed scenario is not good for that.

1 · Retrieval cost is constant

The injected block is k facts per call, whatever the store holds. Measured across two scenarios while the store grew by an order of magnitude:

store grew to injected per call
incident 9,250 chars (12×) 8 facts · 596–961 chars
vecdb 7,655 chars (10×) 8 facts · 725–1,065 chars

Once there are more than k facts to choose from, injection stops tracking the store. A session can accumulate indefinitely without the per-turn bill following it — recall cost is set by k and by the size of one claim, both constants.

2 · The payload is small, because a claim is not a document

search_memory returns whole saved documents. This returns the claims that matter. Same turn, same question:

stock       3,653 chars  (~913 tokens)   N documents × whatever the agent saved
fact graph    793 chars  (~198 tokens)   k claims    × one claim

Per-claim capture is what makes that bound hard. The stock ceiling is soft: save bigger blobs and retrieval grows with them.

And it does not accumulate. Stock recall arrives as a tool result in the message history, so it is re-sent on every later call in the turn — input climbs ~710 → ~1,600 → ~2,400 tokens across three calls. Here recall lives in the system prompt, rewritten each call, so it is paid once and replaced.

3 · At the same context budget, it beats the stock store

This is the property most easily overstated, so it is worth stating narrowly. Kind, status and provenance participate in retrieval:

effect
directive facts held out of retrieval — see Kinds
supersedes chains a hit on any version resolves to the current one, so matching the old value returns the new
derived_from a restatement is dropped when the fact it was derived from is already selected
descendant facts demoted — a sub-agent's raw exploration stays reachable without displacing the parent's constraints
framing never stored — "New shift taking over." was once the top-ranked fact for "what should we try next"

Measured against LangGraph's own store, at the same context budget, this wins. On LongMemEval knowledge-update, n=78, both arms held to 1,200 tokens:

PostgresStore over whole turns   42/78
this fact graph                  51/78     +9, winning 22 head-to-head, losing 13

Question-by-question outcomes over the same 78 questions. Against transcript only at a 1,200 token budget, facts win 35 and lose 7. Against LangGraph's PostgresStore at the same budget, facts win 22 and lose 13 with 29 both correct. Against transcript only at 6,000 tokens it is 4 to 5 — a coin flip.

Plus invalidation a flat store cannot express at all: on qwen3.5:9b, uses_corrected_deploy_time goes 0/3 → 3/3.

The comparison has to be budget-matched to mean anything. Given an unbounded window the store can simply take 7.5× more context and will win on accuracy alone (57/78 at 2,473 tokens against 48/78 at 328) — that measures budgets, not memory strategies. The Ranking section below is also blunt about how little of the graph the ranker consults today; the win here is real but there is headroom in it.

Caveat on the per-call figures below: they were recorded before the num_ctx truncation was found, and the largest of them (~2,400 tokens on a single call) exceeds the 2,048-token ceiling Ollama was silently applying — so the shape of the mechanism is right but the magnitudes need re-measuring.

One turn, call by call. Stock memory returns whole documents as a search_memory tool result that lands in the message history and is re-sent on every later call, so input grows from ~710 to ~2,400 tokens across the turn. The fact graph injects eight ranked claims into the system prompt, which is rewritten each call rather than accumulated, so input stays roughly flat.

Measured effect

The result that matters is the shape, not a single cell. Both arms below get an identical token budget and differ only in how it is spent — transcript fills it with recent conversation, facts + transcript spends ~950 characters on retrieved facts and fills the rest. LongMemEval knowledge-update, n=25:

context budget transcript only facts + transcript Δ n
1,200 tokens 23/78 (29%) 51/78 (65%) +28 78
3,000 tokens 19/25 (76%) 20/25 (80%) +1 25
6,000 tokens 66/78 (84%) 65/78 (83%) −1 78

At 1,200 tokens the pairwise split is 35 questions won to 7 lost. At 6,000 it is 4 to 5 — a coin flip.

Memory's return declines to zero as context becomes sufficient. It is worth a third of the benchmark when the transcript is badly clipped and nothing at all when it nearly all fits, for a constant ~350 tokens per call.

An earlier version of this table (n=25) showed −2 at 6,000 and was described here as measured interference. At n=78 that is −1 with a 4-to-5 pairwise split, i.e. a tie. The claim is "memory stops paying", not "memory costs you".

Both arms get an identical token budget and differ only in whether part of it is spent on retrieved facts. At 1,200 tokens transcript alone scores 23 of 78 and facts plus transcript scores 51; at 3,000 tokens, 19 against 20 on 25 questions; at 6,000 tokens, 66 against 65 — a tie. Memory's return declines to zero as context becomes sufficient. That is the argument for injecting conditionally rather than always, and it is measured rather than reasoned.

The mechanism is that an external retriever competes with attention and loses on material attention can already reach. search() makes one hard top-k commitment from a single query vector before the model reasons; attention selects softly, per head, per layer, over the whole prompt. Filtering cannot add information — so it only pays when the alternative is not having the material at all.

Under scarcity, against the alternatives

The curve above is memory against raw transcript. This is memory against the things you would actually reach for, all at the same 1,200-token budget, n=78 — with the accuracy ceiling being full context, which sees the entire haystack at 6,337 tokens and scores 64/78:

at a 1,200-token budget correct % of the ceiling % of the context
transcript only 23/78 36% 19%
document store (PostgresStore over whole turns) 42/78 66% 15%
this library — facts + transcript 51/78 80% 19%

Cutting the window to a fifth costs 64% of the ceiling with raw transcript, and 20% with the fact graph on top. Against the document store it is +9 questions, winning 22 head-to-head and losing 13 — at 23% more tokens, because the document arm fills whole turns and stops short of its allowance.

Accuracy plotted against context spent. At roughly 1,200 tokens the three strategies are 36 points apart — transcript only 29.5%, LangGraph's PostgresStore 53.8%, facts plus transcript 65.4%. By roughly 5,400 tokens they converge within 1.3 points of each other and of the whole-transcript ceiling.

That is the claim worth making: four-fifths of what full attention achieves, on a fifth of the context. Not better than attention — attention wins whenever the material fits — but close enough that the trade is worth making the moment it does not.

The bm25 arm is omitted here deliberately: a bug meant it was not budget-capped and ran at roughly double everyone else's context, so its score is not comparable. It is in the results file, marked.

What the evidence does not yet cover

Everything above is LongMemEval knowledge-update, n=78, one model (gemma4:12b), one question category. It is external and MIT-licensed, which is the point — the two scenarios this project ships are, in results/README.md's own words, "a designed demonstration, not a neutral benchmark".

Three limits worth stating plainly, because each is a follow-up rather than a settled result:

Longer sessions are untested. The haystack here is ~6,900 tokens, so scarcity had to be created by capping the budget rather than by lengthening the conversation. That simulates a small-context model; it does not simulate a long session. LongMemEval_S — ~115k tokens over 40 sessions, where full context is not an option and most of the haystack is distractors — is the next run, and it is the setting where a filter could plausibly beat full context outright by removing noise rather than merely surviving truncation.

One-shot QA exercises half the design. LongMemEval asks a question about a finished conversation, so there is no "recent" context — it tests the facts half with the transcript half amputated. The shipped middleware sends windowed raw messages plus an injected block.

The two built-in scenarios are follow-up work. incident and vecdb are multi-turn and do exercise both halves, but their results are N=1 per cell here and noisy: the same configuration has produced 3/6, 6/6 and 4/6 on identical code. They are recorded in results/ and are not load-bearing for anything claimed above.

How it compares to LangGraph's built-in memory

Side-by-side comparison: stock LangGraph memory (checkpointer plus LangMem extraction store) versus langgraph-beads-memory (typed fact graph), running the same three-conversation scenario in lockstep through capture, a new thread, delegation, a corrected constraint, and the final answer.

Both lanes run the same scenario, step for step. The structural differences that matter:

stock LangGraph memory langgraph-beads-memory
capture agent must call manage_memory — if it doesn't, nothing persists user input + final answers captured automatically, verbatim
granularity one memory per saved document one fact per claim, each separately embedded and supersedable
across threads new thread_id resets history; agent must decide to search the store one session_id spans threads; relevant facts injected automatically
revising a fact old and new documents coexist; nothing marks which is current typed supersedes edge — a hit on the stale value resolves to the current one, and the old value stays queryable
questions vs claims undifferentiated directive kind kept for provenance but held out of retrieval
sub-agents results return as messages; no link back to what produced them own namespace, enforced conclude_task, rollup_of audit edges
a sub-agent's raw findings mixed into one flat store demoted in the orchestrator's retrieval, and directly readable via recall_from_subagents
a crashed sub-agent silently returns nothing wrapper synthesizes a "did not complete" fact

What the built-in option does well, and what this costs. The checkpointer gives complete message history within a thread, BaseStore has real vector search, and it's first-party with no extra dependency — when the agent does save a memory, cross-thread recall genuinely works. This library adds a dependency and a Postgres schema.

One caveat on the token figures: this library also trims the message window to the last 10 messages. In these turns (2–4 calls each) that almost certainly never binds, so it is unlikely to be contributing — but it has not been isolated, and it would matter in longer turns.

How it works

How langgraph-beads-memory works: user messages and agent conclusions become durable facts on a session-wide memory string; sub-agents fork isolated namespaces and roll summaries back; a revised budget supersedes the stale one; a later conversation on a new thread recalls only active facts.

One session (session_id: vecdb-research) spanning three conversations. Facts are beads on the session string — threads come and go, the string stays.

  1. Capture — every user message is written verbatim onto the session string, no extraction LLM involved. The turn's final answer is captured as a conclusion automatically, so durable memory never depends on the model remembering a tool call.
  2. Fork — delegating research gives each sub-agent its own child namespace. It reads upward (its ancestors) and never sideways (a sibling). The orchestrator reads downward too, demoted — see Ranking.
  3. Enforced rollup — each sub-agent must call conclude_task. One summary lands on the parent, linked by rollup_of edges back to its raw exploration. A crashed sub-agent leaves a "did not complete" fact rather than vanishing.
  4. Supersede — when the user revises a constraint, the new fact supersedes the old one. Retrieval then resolves any version of that claim to the current value, rather than hiding the stale one and leaving the slot empty.
  5. Recall — a brand-new thread starts warm, and only active facts can reach the model. This is exactly where thread-scoped memory starts cold, and where extraction stores surface both the old and new value with nothing marking which is current.

Where memory is written, and how it is ranked

Four write triggers converge on one path — split per claim, drop conversational framing, classify statement or directive, derive a content-addressed id — then retrieval scopes to the namespace and its ancestors and descendants, applies four filters, ranks by cosine distance plus a penalty for descendant facts, and injects the top eight.

Every filter in that pipeline traces to a measured failure rather than a design preference — the notes under the diagram say which. Two examples: the query is taken from the full message list because taking it from the trimmed window meant a turn with enough tool calls produced no query at all and memory silently switched off; and directive facts are held out because questions rank highly against a query precisely by resembling it, and four of eight injected slots were once question fragments.

You can read this off any run rather than taking it on faith:

uv run python -m demo.show_memory results/fresh-gemma/incident --turn conv-3

which prints the ranked facts that actually reached the model, with cosine distances and demotion flags, plus what the run stored broken down by kind and source.

The memory model

Memory hierarchy: a session scope containing a root namespace and isolated child namespaces per sub-agent; the anatomy of a single fact with its kind, status and source; the four fact kinds with directives held out of retrieval; the active/superseded/archived lifecycle; and the typed edges between facts.

Scope: sessions, namespaces, and what can read what

session_id                          one long-lived memory scope, spanning many thread_ids
 └── root namespace  {}             the supervisor's memory
     ├── {task, sub-3f2a}           a forked sub-agent
     ├── {task, sub-91cc}           another, isolated from its siblings
     └── {task, sub-d04e}

A sub-agent reads its own namespace plus its ancestors, never a sibling's. That is what stops three parallel researchers contaminating each other's context.

The relationship is deliberately asymmetric: a parent additionally reads its whole subtree, with a rank penalty. Descendant visibility is a parent privilege, not a general relaxation — children still read only upward, so siblings remain mutually invisible.

Namespace ids are deriveduuid5(session_id, extra_path) — so replaying the same conversation into an empty database reproduces the same ids. The one deliberately random element is the child suffix (sub-3f2a), because two concurrently spawned sub-agents must not collide.

Kinds: what a memory is

kind what it holds retrieved by default?
user_input a claim the user stated, captured verbatim yes
directive a question, instruction, or stated goal no — stored and queryable, held out of retrieval
conclusion something the agent concluded yes
summary a sub-agent's rollup into its parent yes

A user message is split into one fact per claim: "the budget is $100k per year, it must be self-hostable, and I only trust primary benchmark data" becomes three facts, not one. Each then gets its own embedding, and a supersedes edge can retire one without touching the others.

This is a trade, not a free win, and both sides are measured. Stored as one row, a multi-topic message gets one embedding averaged across every topic in it — a root cause captured that way ranked 30th of ~90 and never reached the top-8, while a document store's un-split version ranked 7th of 10 and got used. One row also makes invalidation all-or-nothing: a single supersedes edge retires every constraint sharing it.

Against that, splitting destroys co-occurrence. "25:50" alone has almost no surface for a query to match; "I got my 5K down to 25:50" has plenty. A document store keeps that adjacency for free. The partial compensation is a second vector — every fact stores its own embedding and the embedding of the text it was carved from, and ranking blends the two, so a thin claim from a relevant passage still gets pulled up.

Splitting is heuristic and verbatim: no LLM, no paraphrasing. Passive capture runs in the model-call hot path and cannot afford an extraction call, and the verbatim-record guarantee has to survive per fragment or the audit trail stops being one. The bias is toward under-splitting — a long fragment is harmless, a shredded one pollutes retrieval permanently.

directive exists because questions rank highly against a query precisely by resembling it. Measured: four of eight injected slots were question fragments, displacing the constraint the answer needed. Directives are provenance — they explain why work happened — so they are kept and remain queryable via search(include_directives=True); they just don't compete for the retrieval budget.

Status: retirement is not deletion

status meaning
active retrievable
superseded replaced by a newer fact — still retrievable, but a hit on it resolves to the fact that replaced it
archived compacted — out of retrieval, kept for audit

Nothing is ever deleted, and since 2026-08-20 nothing is hidden either. A superseded fact used to be excluded from retrieval. It is now resolved: match any version of a claim and you get the current one. That matters because excluding the stale value leaves the slot empty, which does not help an agent that was about to answer with it — the measured failure was answering "you currently have three bikes" when the user owns four. Only archived is out of retrieval, because compaction asserts the content is represented elsewhere.

Source: which path wrote it

source when
passive_capture user messages and final answers — automatic, no tool call, no LLM
remember_tool the agent deliberately called remember_fact
tool_result what a non-memory tool returned, captured into the calling agent's namespace — so a sub-agent's raw reads stay in its own namespace rather than crowding the root. On by default since 2026-08-20; it is the only path that reaches a figure the sub-agent read and dropped when summarising
conclude_task a sub-agent's enforced rollup
fallback_conclude the sub-agent crashed or forgot; the wrapper synthesised one
compaction produced by compaction (designed; not exercised in the demo)

Edges: how facts relate

relation meaning
supersedes replaces. Guarded — refused unless the two facts are semantically close (cosine ≥ 0.55)
rollup_of a summary points back at every raw exploration fact behind it — the audit trail
contradicts asserted to conflict
relates_to associated
derived_from a compaction summary points at what it replaced

The supersedes guard exists because an agent once retired the user's entire constraints message with "The investigation into Weaviate has been completed." Nothing checked the two facts were about the same thing. A rule based on fact kind would not have worked — the one legitimate revision had the identical shape — so the guard uses similarity instead.

Ranking

The retrieval score is deliberately simple, and worth stating plainly rather than leaving implied by the machinery around it:

resolve   every candidate to the head of its `supersedes` chain, then dedupe
score     claim_weight · cosine(fact, query)
        + (1 − claim_weight) · cosine(the text it was carved from, query)
        + 0.15  if the fact lives in a descendant namespace
drop      a candidate whose `derived_from` parent is already selected
tiebreak  md5(body), so near-ties do not resolve by insert order
take top 8

The rest are filters: status <> 'archived', kind <> 'directive', has an embedding, not already visible in the raw message window, namespace in scope, and a per-agent cap so one sub-agent cannot take every delegated slot.

Two parts of the graph now participate in ranking, and the rest still does not. supersedes decides which version you get, and derived_from drops a restatement when the fact it was derived from is already in the block — the only signal here that reads recorded provenance rather than inferring redundancy from a vector. rollup_of and relates_to still have no effect on what is injected, kind is a binary exclude, and there is no recency, frequency or diversity term.

That remaining gap is visible in the measured results: with no diversity term, an enumeration question ("list everything we ruled out") can spend several of eight slots on one subsystem, and a fact ranked 9th to 11th is simply unreachable at k=8 however relevant it is.

And a better ranker has a ceiling worth knowing about. Ranking competes with attention, which selects softly, per head, per layer, over everything in the window — while search() commits to a hard top-k from one query vector before the model reasons at all. Retrieval is a function of the context, so it cannot add information; filtering can only lose. That is why the measured advantage decays to zero once the conversation fits, and why the useful lever is deciding what enters the window rather than ordering what was already going to.

Signals a fuller ranker would likely carry, none of which exist here: recency, kind weighting (a stated constraint arguably outranking an agent's own conclusion), edge awareness, and diversity. The contribution of this project is the typed, auditable store; retrieval is currently a thin layer over pgvector sitting on top of it.

Identity

A fact's id is derived from (session_id, namespace_id, source, source_key, sha256(body)). Content-addressed, so a LangGraph checkpoint replay re-running a capture hook writes nothing new rather than duplicating.

Why not the built-in primitives

LangGraph ships a checkpointer for thread-scoped state and a BaseStore / PostgresStore for cross-thread key-value memory. Frameworks on top (LangMem, Mem0, Zep) mostly bet on automatic LLM extraction: scan the transcript, pull out "facts", write them somewhere. That is fast to wire up, but imprecise, hard to audit, and it gives you no way to say this conclusion replaced that one or this sub-agent's exploration should not pollute the parent's context.

beads — Steve Yegge's dependency-aware issue tracker for coding agents — took a typed-graph stance for task memory: explicit bd remember calls, and semantic decay rather than silent deletion. This brings that stance to LangGraph's conversational and multi-agent memory.

Two commitments follow, and they constrain everything else:

  • Postgres only. Namespaces, facts and edges live in one schema, pgvector for embeddings. No graph database, no separate vector store.
  • Session-scoped, not identity-coupled. The schema is anchored on session_id — a memory scope that deliberately spans LangGraph threads, so a new conversation continuing the same work starts warm. The library does not need to know what a "user" is; an application wanting a user↔session mapping owns that table.

Running it yourself

Everything runs locally: Postgres for storage, Ollama for inference. No API keys.

Setup

docker compose up -d                       # Postgres + pgvector on :5433
brew services start ollama                 # or: ollama serve
ollama pull gemma4:12b                     # the default model
ollama pull nomic-embed-text               # embeddings, 768-d
uv sync --all-extras                       # includes the demo + playground extras

Any model works if it advertises tool calling and fits your hardware. The benchmark set is gemma4:12b, qwen3.5:9b, lfm2.5:8b — select one with BEADS_DEMO_MODEL. granite4.1:8b and ministral-3:14b were benchmarked and dropped — the runs, the reasons and what the exclusion changes are in results/excluded/.

The gate — run this first

uv run python -m demo.smoke_test

Three checks: structured tool calls, extraction-shaped output, 768-d embeddings. A model that fails any of them produces a meaningless comparison in one direction or the other, so fix the model rather than proceeding.

The benchmarks

Two scenarios. vecdb picks a vector database across 3 threads; incident debugs a production incident across 4. Arms are baseline (LangMem + PostgresStore), treatment (this library), and two ablations.

An external benchmark, which the two scenarios above are not:

# LongMemEval knowledge-update: this library vs a document store, bm25 and full context
uv run python -m demo.longmemeval --data longmemeval_oracle.json \
  --type knowledge-update --arms bm25 baseline memory fullcontext

# the budget pair — identical token budget, differing only in how it is spent
uv run python -m demo.longmemeval --data longmemeval_oracle.json \
  --type knowledge-update --arms tail augment --budget 3000

Dataset: xiaowu0162/longmemeval-cleaned on HuggingFace (MIT). Always pass --num-ctx — Ollama defaults to 2,048 and truncates silently.

# one paired run of each scenario
uv run python -m demo.harness --runs 1 --scenario incident --conditions baseline treatment
uv run python -m demo.harness --runs 1 --scenario vecdb    --conditions baseline treatment

# a single cell, e.g. to re-run one arm
uv run python -m demo.harness --scenario incident --only treatment:0

# N=5 across all four arms
uv run python -m demo.harness --runs 5 --scenario incident \
  --conditions baseline treatment treatment-nosupersede treatment-subrecall

# the full model x arm matrix, restarting Ollama between runs
scripts/run_model_matrix.sh incident 3 "gemma4:12b qwen3.5:9b" "baseline treatment"

Runs land in results/raw/. On a 16GB M4 a single incident run is roughly 10–18 minutes, so plan the matrix accordingly.

Reading the results

uv run python -m demo.aggregate  results/raw          # per-metric table, one scenario
uv run python -m demo.compare_models results/matrix   # paired within-model deltas
uv run python -m demo.judge      results/raw          # blinded LLM judge
uv run python -m demo.show_memory results/raw --turn conv-3

show_memory is the one worth knowing: it prints what a run stored, by kind and source, and for any turn the ranked facts that actually reached the model with their cosine distances. The ranking is readable rather than asserted.

scripts/reset_db.sh          # drop every run schema and start clean

The playground

A live two-pane chat: you type once, both memory layers answer, side by side. Each chat is one session, and every message runs on a new LangGraph thread — so neither side can lean on message history, and anything recalled came from its memory layer. It uses web search rather than a fixed corpus.

uv run uvicorn playground.app:app --port 8100
# then open http://localhost:8100

Turns run server-side, so closing the tab does not strand them, and an unfinished turn resumes on restart. Under each treatment reply is the ranked set of facts it injected, with distances. Chats persist to playground/.chats.json; the memory itself lives in Postgres.

To try the mechanism deliberately: state a constraint, ask something unrelated, correct the constraint, then ask a question that depends on it. The correction is where the two diverge.

Using it

from beads_memory import BeadsMemoryMiddleware, BeadsStore, OllamaEmbedder, make_subagent_tool

store = BeadsStore(conn)          # any psycopg connection; one per thread
store.init_schema()
ns = store.get_or_create_namespace("vecdb-research")   # the session scope

agent = create_agent(
    model=llm,
    tools=[...],
    middleware=[BeadsMemoryMiddleware(
        store=store, namespace=ns, embedder=OllamaEmbedder(),
        agent_id="root", acting_on_behalf_of="user",
    )],
)

Capture and injection then happen automatically; there are no store calls to write in the common path.

Tools the middleware binds for the agent

tool who gets it what it does
remember_fact every agent record a conclusion, optionally supersedes/contradicts/relates_to an existing fact by short id
conclude_task forked sub-agents required before returning; writes one summary into the parent with rollup_of edges back to the raw work
recall_from_subagents orchestrators only read what a named sub-agent actually recorded, past its one-line summary

recall_from_subagents exists because demoted search is a guess — a child's fact surfaces only if the query happens to match it. An orchestrator usually knows something stronger: it delegated a topic to a named researcher. This lets it look rather than hope. It is bound only where capture_final is set, the same flag that distinguishes a root agent from a fork, so sub-agents cannot use it to read siblings.

Reading the store directly

store.search(ns.id, embedder.embed(q), k=8)   # self + ancestors + demoted descendants
store.children(ns.id)                          # direct sub-namespaces
store.subtree_facts(ns.id, agent_id="researcher_qdrant")   # what one sub-agent found
store.facts_in_namespace(ns.id)                # everything here, including retired

search and subtree_facts return only active facts. facts_in_namespace does not filter, which is how you audit what was superseded and by what.

How it fits into a LangGraph app

It's an agent middleware (LangGraph's create_agent pre/post-model hook API) — not a BaseStore implementation and not a checkpointer replacement. Thread-level state/replay stays with LangGraph's own PostgresSaver; this owns a separate schema for durable, structured memory and wires in purely through hooks, so there are no explicit store calls to write in the common path.

Per turn, the middleware:

  1. Passively captures new user messages as facts (no LLM call), and the turn's final answer the same way.
  2. Captures what non-memory tools return, into the namespace of the agent that called them.
  3. Trims the context to a sliding window of the last ~10 raw messages.
  4. Runs semantic search over the current namespace, its ancestors, and (for a parent) its demoted descendants, resolves each hit to the current version of its claim, and injects the top-K.

Known gap between design and code. This README used to say older messages "get distilled into facts as they roll off". They are not — capture fires eagerly on every message, whether or not it has left the window, which is why an agent's own answers accumulate in the store while still visible on screen. Capturing at the points where context is actually destroyed — window eviction, sub-agent fork and merge, end of thread — is designed and unbuilt; see open questions.

Full schema, hook lifecycle, idempotency guarantees, and error handling are in the design spec (linked below).

Comparison

No existing tool combines all of this. The closest points of comparison:

LangGraph-native Mem0 Zep/Graphiti Cognee Letta langgraph-beads-memory
Postgres-only Strong Weak (graph mode needs Neo4j) Absent (Neo4j) Strong Adequate Strong
Typed fact graph Absent Weak Strong Adequate Absent Strong
Explicit (non-blind) capture Adequate Weak Weak Weak Adequate Strong
Enforced sub-agent fork + rollup Absent Absent Absent Absent Adequate Strong

Full writeup, positioning, and strategic analysis in the competitive brief (linked below).

Project status

  • Architecture design (spec)
  • Competitive landscape research (brief)
  • Demo/benchmark design (spec)
  • langgraph-beads-memory package — store, middleware, tools, sub-agent fork/rollup. 255 tests against real Postgres.
  • Comparison harness — two scenarios, four arms, objective metrics, blinded LLM judge with a grounding dimension
  • Instrumentation — every run records what retrieval injected (with cosine distances) and a snapshot of what it stored, so rankings are read rather than reconstructed: uv run python -m demo.show_memory <run-dir>
  • Diagramscontext-budget curve, accuracy per token, pairwise outcomes, comparison, mechanism, write + ranking pipeline, why it costs less context
  • Scenario rounds, N=3, superseded — four rounds on qwen3:8b plus the 24-run two-model matrix. Kept for provenance and for the corrections they document; not evidence for any claim above. results/README.md opens with what did not survive.
  • Pre-registered second scenariopredictions committed before the first run, including the two metrics the baseline was expected to win
  • An external benchmark — LongMemEval knowledge-update, n=78, against LongMemEval's own bm25 reference and a whole-turn document store: demo/longmemeval.py
  • The context-budget curve — the result that reframes the project; memory's return measured against available context at matched budget
  • Longer sessions — the next thing to publish. Everything measured so far runs on a ~6,900-token haystack, so scarcity had to be manufactured by shrinking the budget. LongMemEval_S is ~115k tokens across 40 sessions, where full context is not an option at all and ~95% of the haystack is distractors.
    • The prediction, stated before the run: the curve holds — the memory layer retains most of full-context accuracy at a fraction of the tokens, and the advantage grows as the session lengthens, because the fraction of the conversation that fits keeps falling.
    • What would falsify it: if the advantage shrinks with session length, the gain measured here was an artefact of clipping a short transcript rather than a property of long sessions. A second possibility worth separating: on a haystack that is mostly distractors, filtering could beat full context outright by removing noise that steals attention mass — which would be a stronger result than the curve predicts, and would mean the current framing understates the case.
  • Pressure-gated injection — inject nothing while the history fits. The design the curve implies; not built
  • Re-measure with num_ctx set — every prior run was truncated at 2048 tokens
  • Model study — three models, five of six cells still N=1
  • Part 1 publishedBuilding a Fact-Driven Memory Layer for LangGraph: retrieval under a fixed context budget, on an external benchmark
  • Part 2 — longer sessions, and pressure-gated injection

Method, every disclosed correction, and the operational notes are in results/README.md. It is long on purpose: several rounds ran with bugs that were later found and fixed, and each one is recorded with what it changed rather than quietly re-run.

Running the demo needs Docker (Postgres + pgvector) and Ollama; see results/README.md for exact steps.

Evidence, and its limits

The mechanism is verified end to end against live Postgres — forked child namespaces, conclude_task rollups, rollup_of audit edges — not only in unit tests.

On the comparison, the honest summary is:

  • Retrieval cost being constant and small is architectural, and holds in every run: injection is k claims per call and does not track the store.
  • Whether a run is cheaper overall depends on the model. Memory injection is a small share of an agent turn's total input, so a verbose model's own message history can swamp the saving. The per-run share was measured before the num_ctx truncation was found and has not been re-measured.
  • The accuracy claim is conditional, and the condition is the whole point. At a matched context budget it wins: 51/78 against the stock store's 42/78 at 1,200 tokens. Given an unbounded window it does not — more context beats less, and the curve goes to zero by 6,000 tokens. Both are stated above; they are not in tension, they are two ends of one measurement.
  • On this project's own two scenarios, no accuracy gain is established. N=3, two models, 24 runs: three of four cells move by less than one metric and the fourth is a regression. Those scenarios are follow-up work, not evidence for anything claimed here. Earlier N=1 rows read as wins did not survive — vecdb gemma went 6/6 to 5,5,6, a qwen token penalty of +11.6% reversed to −8.9%, and an incident breadth score of 1 became 3 on identical code.
  • The value is conditional on context pressure, and negative without it. At a matched budget, injected facts are worth +6 of 25 when the transcript is clipped and −2 of 25 when it nearly all fits. Injecting unconditionally is a tax in the regime most agent turns occupy.
  • Every token figure before 2026-08-19 was measured under silent truncation. Ollama's default num_ctx is 2,048 and demo/llm.py never set one, so any prompt above that was cut before evaluation and reported as 2,051 tokens. It fell hardest on the baseline, whose prompts grow within a turn.
  • The memory layer injects run-to-run variance. At temperature 0 all four baseline cells scored identically three times; every treatment cell had spread 1–2, with only 4 of 8 injected facts common across three runs of one turn.
  • The model set was narrowed after results were known. Two of the original five were dropped — one that could not execute the scenario, one whose second scenario was confounded — and that removed the two cells where this library cost more input. The reasons and the before/after figures are in results/excluded/; the cost claim should be read as a claim about three models rather than about memory-augmented agents.

Method, every disclosed correction, and the operational notes: results/README.md. It is long on purpose — several rounds ran with bugs that were later found and fixed, and each is recorded with what it changed rather than quietly re-run. The latest round, including five changes that measured as no-ops and the external-benchmark numbers, is 2026-08-19. How the benefit differs by model is a separate study: results/model-study.md.

Docs

  • Architecture design — namespace model, Postgres schema, capture mechanisms, fork/rollup, compaction, error handling
  • Competitive brief — landscape, positioning, opportunities/threats
  • Demo design — the scenario and methodology used to demonstrate this against plain LangGraph memory

License

MIT — see LICENSE.

About

Beads-style durable memory for LangGraph agents on Postgres — a typed fact/conclusion graph with explicit capture (not blind auto-extraction) and enforced sub-agent memory forking with rollup summaries, instead of an opaque conversation summary.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages