Memory of its own reasoning

Neo4j Agent Memory

A graph-native memory whose third tier records how the agent reasoned and which tools worked — captured through a context manager, so failures are stored automatically rather than only successes.

Carries 2 of 7 rubric mechanisms. Most systems here carry none or one (41%), and a dash means the mechanism was not found at this commit — not that the system needed it. Each mark is one LLM reviewer's reading of the code at this commit rather than a run of it — known limits.

  • Tombstone
  • Trust state
  • Bi-temporal
  • Scope enforced
  • Mutation audit
  • Human review
  • Negative evals

1. Executive Summary

Neo4j Labs' agent-memory is an Apache-2.0 Python package of about 45,000 lines under src/neo4j_agent_memory/, positioned as "a graph-native memory system for AI agents… let your agents learn from their own reasoning — all backed by Neo4j". It complements Graphiti, which is also Neo4j-adjacent and already in this atlas, but is a different package aimed at agent tooling rather than at temporal knowledge graphs.

Three tiers: short-term, long-term, and reasoning. The third is the reason to read it.

memory/reasoning.py records "reasoning traces and tool usage" — and it records them through a context manager, which means the interesting case is handled without anyone remembering to handle it:

self._outcome = str(exc_val) if exc_val else f"Error: {exc_type.__name__}"

When a traced task raises, the exception becomes the trace's outcome on the way out. Failures are stored by default. Nearly every procedural-memory system in this atlas records successes — Voyager writes a skill only after verified execution, and the skills as procedural memory pattern is built around that gate. Storing what did not work is the other half, and it is rare: Verel has a failure ledger and little else here does.

The outcome is structured rather than a string:

class TraceOutcome(BaseModel):
    """Structured outcome attached to a reasoning trace at completion time."""
    success: bool
    summary: str
    # plus an indexable error kind, structured related entities, and metrics

with the docstring noting it "lets a caller attach an indexable error kind… while remaining fully back-compatible with the legacy string + boolean API". An indexable error kind makes "what else failed this way" a query.

Two more things are worth a builder's attention. Multi-tenancy is fail-closed on the write path_enforce_multi_tenant (memory/long_term.py:431) "raise[s] if multi-tenant is on and user_identifier is missing", so a preference cannot be stored unattributed once the mode is on. And preferences carry valid_from / valid_until with a supersede_preference that is explicitly "idempotent: re-running on an already-superseded preference is a" no-op.

Which read path carries the scope key is worth stating exactly, because only one does. get_preferences_for (:830) takes a user_identifier and compiles it into the pattern — MATCH (u:User {identifier: $user_identifier})-[:HAS_PREFERENCE]->(p:Preference) (:854) — so the stored key reaches the query and scope_enforced is earned there. The semantic surface does not: search_entities (:1073), search_preferences (:1139) and get_context (:1260) accept no user parameter at all, and the last of those is the one whose docstring reads "Get long-term context for LLM prompts". So the boundary holds where a caller asks for one user's preferences by name, and the path an agent uses to assemble its context is unscoped. _enforce_multi_tenant does not close that: it is an argument check on writes, and no amount of fail-closed writing filters a read that never takes the key.

Reservations: bi-temporal validity and supersession apply to preferences, not to every memory kind; there is no value-level tombstone; and the reasoning tier's value depends on retrieval surfacing past failures at the moment they would help, which was not traced.

2. Mental Model

Store Holds
short-term conversation turns, recent context
long-term entities, relationships, preferences — a preference carries valid_from and valid_until, and supersede_preference(old, new) is idempotent
reasoning a trace of steps and tool calls; the with-block makes success or the raised exception the outcome on exit, recorded as TraceOutcome{success, summary, error kind, entities, metrics}

Two details worth taking. Preferences are the only thing here with validity time, and an idempotent supersede means a retried write cannot double-supersede. And the reasoning trace cannot silently succeed: leaving the block always records an outcome, including the exception.

All three live in one Neo4j instance shared across agents rather than in per-agent stores — the stated position being a single brain rather than silos.

How a memory stops being current: a preference is superseded and its valid_until closes. Nothing else has a defined end state, and nothing records that a value was judged wrong.

Diagram — one shared graph rather than per-agent silos, with reasoning traces recorded through a context manager and supersession covering preferences only
Diagram source
%% caption: one shared graph rather than per-agent silos, with reasoning traces recorded through a context manager and supersession covering preferences only
flowchart TB
    Turn["Conversation turn"] --> Cap["Capture"]
    Cap --> GL["GLiNER extraction"]
    GL --> G[("One Neo4j graph<br/>shared across agents, not per-agent silos")]
    Trace["Reasoning trace<br/>recorded via a context manager"] --> G
    Pref["Preference"] --> Sup["supersede_preference<br/>idempotent, valid_from … valid_until"]
    Sup --> G
    G --> Q["Graph queries over entities<br/>and relationships; embeddings;<br/>reasoning steps with parent context"]
    G -.->|"multi-tenant mode raises when<br/>a user identifier is missing"| Scope["fail-closed tenancy"]
    Sup -.->|"covers preferences only"| Gap["no value-level tombstone"]

3. Architecture

src/neo4j_agent_memory/memory/long_term.py (2,357), __init__.py (1,827), memory/short_term.py (1,727), memory/reasoning.py (1,394), graph/queries.py (1,385), extraction/gliner_extractor.py (1,256), integrations/strands/tools.py (1,014), mcp/_tools.py (963), cli/main.py (954), config/settings.py (823), extraction/pipeline.py (787), plus core/, llm/, embeddings/, nams/, observability/, memory/consolidation.py, memory/users.py, memory/eval.py.

Entity extraction uses GLiNER — a local NER model — rather than an LLM call, which puts a meaningful part of the write path outside the token budget.

Deployment and ergonomics

Needs a Neo4j instance; there is a docker-compose.test.yml and a deploy/ tree. Beyond that it is a Python package with an MCP server, a CLI, and integrations for AWS Strands and the OpenAI Agents SDK.

The shared-graph design is the ergonomic trade: one database to run, and every agent sees the same entities — which is the point, and also means a mistake by one agent is visible to all of them.

4. Essential Implementation Paths

Failures recorded because the control flow records them

The context-manager shape is what makes this reliable rather than aspirational. A system that asks callers to log failures gets failure logs proportional to discipline; a system where __exit__ captures the exception gets them proportional to coverage.

The atlas has repeatedly noted that memory systems remember what worked. The skills as procedural memory pattern's gate — write only on verified execution — is correct and one-sided: an agent that has failed the same approach four times has learned nothing, because nothing wrote it down. Here the failing path is the default path.

ReasoningStepWithContext — "a reasoning step plus its parent trace's task and outcome" — matters for the same reason. A step retrieved without knowing whether the attempt it belonged to succeeded is worse than useless, because it reads as precedent.

An indexable error kind

Attaching a typed error kind alongside the human-readable summary turns the trace store into something queryable by failure mode rather than by text similarity. "Show me every trace that failed with a timeout against this tool" is a graph query; against a free-text outcome it is a guess.

The back-compatibility note is worth copying too — the structured outcome was added without breaking the string-plus-boolean callers, which is how a schema gets richer in a released package rather than in a rewrite.

Fail-closed multi-tenancy

def _enforce_multi_tenant(self, user_identifier: str | None) -> None:
    """Raise if multi-tenant is on and ``user_identifier`` is missing."""

The scope as a first-class key pattern asks for scope on the read path. This adds the posture: with the mode on, a query that forgets the tenant fails rather than returning the union. The common bug in scoped stores is not a missing filter clause — it is a code path where the filter is optional and someone omitted it.

The cost is that the guard depends on the mode being on, which is a configuration, not a constraint.

Bi-temporal preferences, superseded idempotently

valid_from is set to now on write; valid_until "stays null until supersede_preference", which is idempotent — re-running on an already-superseded preference does nothing. The code notes a "v0.5 bi-temporal time-travel API on top of valid_from / valid_until".

Idempotency in a supersession path is a small thing that prevents a specific mess: a retried consolidation pass that closes an interval twice, producing a preference whose validity bounds no longer describe anything.

The limitation is the scope of it. Preferences get validity intervals; entities, relationships and reasoning traces do not, so "what did we believe last March" is answerable for one memory kind.

5. Memory Data Model

Conversation turns, extracted entities and relationships, preferences with validity bounds, and reasoning traces containing steps and tool calls with statistics.

What is absent:

  • No value-level tombstone. A superseded preference closes an interval; nothing prevents the same value being re-extracted.
  • No trust state. Outcomes describe whether a task succeeded, not whether a fact is believed.
  • Bi-temporality is partial — preferences only.
  • The shared graph has no per-agent provenance traced here, so which agent asserted an entity was not established.

6. Retrieval Mechanics

Graph queries over entities and relationships (graph/queries.py, 1,385 lines), embeddings, and reasoning-step retrieval that carries parent-trace context. memory/eval.py exists inside the package, and benchmarks/ provides a runner and metrics module.

7. Write Mechanics

Conversation capture into short-term memory, a GLiNER extraction pipeline into the graph, traces written by the context manager, and a consolidation module.

Operational cost

Entity extraction runs on a local NER model rather than an LLM, so the highest- frequency write path costs no tokens. Trace recording is a graph write. The consolidation path is where model cost concentrates.

No figures for extraction latency or graph growth were found, and a shared graph across agents grows with the union of their activity rather than with any one agent's.

8. Agent Integration

An MCP server, a CLI, AWS Strands tools (integrations/strands/tools.py, 1,014 lines), and OpenAI Agents SDK examples. The MCP tool surface is nearly a thousand lines, so the integration is a first-class part of the package rather than a wrapper.

A TypeScript client, a hosted service, and one guard in two places

The largest arrival is typescript/ — a client, not a second implementation. RestTransport "talks to the hosted Neo4j Agent Memory Service REST API" at a /v1 root, so the enforcement described above stays in Python and the client cannot weaken it by reimplementing a query badly. Integrations for Strands, Mastra and the Vercel AI SDK arrived with it.

One surface crosses both languages and is guarded in only one. A read-only Cypher console lets a caller run arbitrary MATCH against the graph, which is an escape hatch around every scoped accessor by construction, so where the read-only check lives is the whole question.

On the Python side it lives in one place and is shared. is_read_only_query (core/query.py:41) is called by the direct-Bolt accessor (:72), by the hosted NAMS accessor (nams/query.py:53) and by the MCP tool (mcp/_tools.py:777) — which imports it under its old private name with a comment recording the move and why: "so the bolt and NAMS Cypher accessors share the same validator." One predicate, three surfaces, no second opinion. The Strands agent tool that exposes the console to a model routes through the NAMS accessor, so its docstring claim that writes are "rejected client-side" is accurate.

The TypeScript QueryConsole.cypher has no such check. It passes the string straight to the transport, and its header states a different guarantee — "Hosted service only: write operations are rejected with HTTP 400" — so the same logical surface is defended client-side in Python and server-side in TypeScript, by a component that is not in this repository:

grep -rn -i "readonly\|isReadOnly\|read_only" typescript/src --include="*.ts"

Nothing at the pinned commit but private readonly field modifiers. This is not a defect in the client — a hosted service that trusts no client is the right design, and a client-side check would be a convenience rather than a boundary. It is a difference in what a reader of this repository can verify: the Python guarantee is checkable here and the TypeScript one is a claim about a service that is not.

9. Reliability, Safety, and Trust

Strengths:

  • Failures captured by control flow, not by caller discipline.
  • Structured trace outcomes with an indexable error kind and metrics.
  • Reasoning steps carrying their parent's outcome, so a step is never read as precedent without knowing whether it worked.
  • Fail-closed multi-tenancy.
  • Idempotent supersession with validity bounds.
  • Local NER extraction, keeping the frequent write path off the token budget.
  • A benchmarks tree and an in-package eval module.
  • Schema extended back-compatibly rather than by rewrite.

Gaps:

  • No value-level tombstone.
  • Bi-temporality limited to preferences.
  • No trust state on facts.
  • A shared graph means shared mistakes — one agent's bad extraction is every agent's context, and no per-agent provenance was traced.
  • The tenancy guard is conditional on a configuration flag.

10. Tests, Evals, and Benchmarks

A benchmarks/ tree with runner.py and metrics.py, an in-package memory/eval.py, a docker-compose.test.yml, and codecov configuration. Nothing was run for this review and no scored results were located.

The measurement this design invites is whether the reasoning tier changes behaviour: does an agent with access to its own failed traces stop repeating an approach? That is a before/after comparison on a fixed task set, and the trace store is the instrument.

11. For Your Own Build

Steal

  • Record traces with a context manager, so a raised exception becomes the outcome on the way out. Failure memory that depends on callers remembering to write it will be proportional to their discipline.
  • Store the failures. Procedural memory that only holds successes cannot stop an agent retrying an approach that has never worked.
  • Give outcomes an indexable error kind, so the store is queryable by failure mode rather than by text similarity.
  • Attach the parent outcome to a retrieved step, or a step from a failed attempt reads as precedent.
  • Make the tenancy guard raise. A missing scope should be an error, not a wider result set.
  • Make supersession idempotent, so a retried pass cannot close an interval twice.
  • Extract entities with a local model where you can — it moves the highest- frequency write off the token budget.

Avoid

  • Bi-temporality on one memory kind. Preferences having validity while entities do not means "what did we believe then" has a partial answer, which is harder to reason about than none.
  • A shared graph without per-agent provenance. One brain is the design goal; not knowing which agent asserted something is a cost of it that should be paid deliberately.

Fit

Right when several agents should share one view of the world and you already run Neo4j — the shared-graph position is the product, and the reasoning tier is the best argument for it, since operational history is exactly the thing worth pooling. Wrong when memories need trust state or durable correction: supersession here closes an interval on a preference, and nothing stops a re-extraction putting the old value back.

12. Open Questions

  • Does retrieval surface past failures at the moment they would prevent a repeat, or only when queried for?
  • Which agent asserted a given entity in the shared graph?
  • Why do preferences get validity bounds and entities not?
  • What happens to a superseded preference on the next extraction pass over the same conversation?
  • Have the benchmarks been run, and against what?

Appendix: File Index

  • Reasoning tier: src/neo4j_agent_memory/memory/reasoning.py (record_tool_call, set_outcome, ReasoningStepWithContext, the context-manager exit).
  • Trace outcome: src/neo4j_agent_memory/schema/models.py (TraceOutcome, EntityRef).
  • Long-term and bi-temporality: memory/long_term.py (supersede_preference, valid_from, valid_until, _enforce_multi_tenant).
  • Short-term: memory/short_term.py; consolidation: memory/consolidation.py.
  • Graph: graph/queries.py; extraction: extraction/gliner_extractor.py, extraction/pipeline.py.
  • Surfaces: mcp/_tools.py, cli/main.py, integrations/strands/tools.py.
  • Evaluation: memory/eval.py, benchmarks/runner.py, benchmarks/metrics.py.

History

2026-09-130303dc00… — 116 commits and about 187,000 added lines past the previous pin, most of it a new typescript/ client and integrations for Strands, Mastra and the Vercel AI SDK. Both marks re-verified in the Python implementation, which the client calls rather than reimplements.

A published claim is corrected in the direction that matters. The first reading named _enforce_multi_tenant as the scope mechanism; it is an argument check on the write path, and it cannot filter a read. The predicate that earns scope_enforced is in get_preferences_for, which compiles the caller's user_identifier into a MATCH (u:User {identifier: $user_identifier}) pattern. The bound now recorded with it: search_entities, search_preferences and get_context accept no user parameter, so the semantic path an agent uses to assemble a prompt is unscoped. The mark stands on the by-user read and the report says which read carries it.

Section 8 records the Cypher console, whose read-only check is shared by three Python surfaces through one predicate and absent from the TypeScript client, which states the guarantee as a server-side HTTP 400 instead. Three separate near-misses were dropped before publication on this reading — a guard that looked missing in nams/query.py and was present, a private alias in mcp/_tools.py that looked like a typo and is documented, and a raw-Cypher escape hatch that looked unguarded in Python and is not — each found by grepping the whole tree rather than the file in hand.

2026-07-28b017db44… — first reading.