Use with LangChain
How to integrate neo4j-agent-memory with LangChain 1.x to build memory-enabled agents and chains backed by a persistent context graph.
|
Supported LangChain range: 1.x. The LangChain 1.0 retired the memory story this page used to teach.
Both are what this library ships. See Legacy: pre-1.0 LangChain if you are maintaining a pre-1.0 application. |
examples/langchain_agent.py is the runnable version of this page; examples/nams-langchain runs the same adapter against the hosted backend.
Overview
The library ships three LangChain adapters, each implementing a base class that exists in LangChain 1.x, plus one pass-through helper.
| Adapter | Implements | Use it for |
|---|---|---|
|
|
Thread history for |
|
|
RAG over all three memory layers — messages, entities, preferences and traces. |
|
|
Give a |
|
— |
Reuse your configured LangChain chat model as memory’s own LLM. |
| LangChain 1.x + Context Graph Architecture |
|---|
|
Prerequisites
-
Python 3.10+
-
A Neo4j database (or a hosted NAMS workspace — see Backends)
-
An API key for your model provider
# Chat history + retriever only
pip install "neo4j-agent-memory[langchain,openai]"
# Plus the create_agent middleware
pip install "neo4j-agent-memory[langchain-agents,openai]" langchain-openai
Pass-through your LangChain model
To avoid declaring the same model twice — once for the agent, once for memory’s
entity extraction — convert your LangChain BaseChatModel into an
LLMProvider:
import os
from langchain_openai import ChatOpenAI
from neo4j_agent_memory import MemoryClient, MemorySettings
from neo4j_agent_memory.integrations.langchain import (
llm_provider_from_langchain,
)
# Configure your LangChain model however you normally would. Temperature is
# left at the provider default: the GPT-5 family rejects non-default values.
chat = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-5-mini"))
# Convert it to an LLMProvider for the memory client.
provider = llm_provider_from_langchain(chat)
settings = MemorySettings(
neo4j={"password": os.environ["NEO4J_PASSWORD"]},
llm=provider,
embedding="openai/text-embedding-3-small",
)
The helper introspects the model’s class name and attributes (model_name,
anthropic_api_key, openai_api_key, openai_api_base, …) and routes via
from_provider. ChatOpenAI resolves to OpenAIProvider; ChatAnthropic to
AnthropicProvider; everything else falls through to LiteLLM.
Note the spelling: model= is the current constructor argument. model_name=
is a legacy alias.
For the full pattern across all integrations, see Migrate to Pluggable Providers — Pattern 5.
Give an agent memory with middleware
Neo4jMemoryMiddleware is the whole integration for an agent: nothing else to
wire up, no prompt template to maintain.
import asyncio
import os
from langchain.agents import create_agent
from langchain_openai import ChatOpenAI
from pydantic import SecretStr
from neo4j_agent_memory import BoltSettings, MemoryClient, Neo4jConfig
from neo4j_agent_memory.integrations.langchain import (
Neo4jMemoryMiddleware,
llm_provider_from_langchain,
)
async def main() -> None:
model = ChatOpenAI(model=os.getenv("OPENAI_MODEL", "gpt-5-mini"))
settings = BoltSettings(
neo4j=Neo4jConfig(
uri=os.getenv("NEO4J_URI", "bolt://localhost:7687"),
password=SecretStr(os.environ["NEO4J_PASSWORD"]),
),
embedding="openai/text-embedding-3-small",
llm=llm_provider_from_langchain(model),
)
async with MemoryClient(settings) as client:
agent = create_agent(
model,
tools=[],
system_prompt="You are a helpful assistant.",
middleware=[
Neo4jMemoryMiddleware(client, session_id="user-123"),
],
)
# Async invocation: the middleware implements the async hooks.
result = await agent.ainvoke(
{"messages": [("user", "Where should I eat tonight?")]}
)
print(result["messages"][-1].content)
asyncio.run(main())
What the middleware does on each turn:
abefore_model-
Persists any user message the agent has not stored yet, so the turn survives a failed model call.
awrap_model_call-
Calls
client.get_context(query, session_id=…)for the latest user message and appends the result to the request’s system message. Agent state is left untouched, so the memory block never accumulates in the thread’s message list. aafter_model-
Persists the assistant’s reply. Messages are de-duplicated by id, so a tool-calling loop that re-sends the same history does not write it repeatedly.
Options:
Neo4jMemoryMiddleware(
client,
session_id="user-123",
include_short_term=True, # conversation history in the injected block
include_long_term=True, # entities, facts and preferences
include_reasoning=True, # similar past traces
max_items=10, # per-layer cap
store_messages=True, # set False for a read-only agent
extract_entities=True, # entity extraction on persisted messages
context_header="# Memory",
)
|
Only the async hooks are implemented, because neo4j-agent-memory is async all
the way down. Invoke the agent with |
Add tools that read the graph
create_agent takes plain @tool functions, and async tools are first class —
no run_until_complete bridging:
import json
from langchain_core.tools import tool
from neo4j_agent_memory import MemoryClient
def build_memory_tools(client: MemoryClient) -> list:
"""Tools that query the context graph directly."""
@tool
async def search_entities(query: str, limit: int = 10) -> str:
"""Search the knowledge graph for people, places, products or organizations."""
entities = await client.long_term.search_entities(query, limit=limit)
return json.dumps(
[
{
"name": e.display_name,
"type": e.full_type,
"description": e.description,
}
for e in entities
]
)
@tool
async def save_preference(category: str, preference: str) -> str:
"""Record a preference the user just expressed (category: food, brand, style, ...)."""
await client.long_term.add_preference(category, preference)
return f"Saved preference: {category} — {preference}"
@tool
async def search_history(query: str, session_id: str, limit: int = 5) -> str:
"""Search earlier messages in a conversation."""
messages = await client.short_term.search_messages(
query, session_id=session_id, limit=limit
)
return json.dumps(
[{"role": m.role.value, "content": m.content[:200]} for m in messages]
)
return [search_entities, save_preference, search_history]
# agent = create_agent(model, tools=build_memory_tools(client), middleware=[...])
Thread history for a chain
Neo4jAgentMemory is a BaseChatMessageHistory, so it drops straight into
RunnableWithMessageHistory for chains that are not agents:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables.history import RunnableWithMessageHistory
from neo4j_agent_memory.integrations.langchain import Neo4jAgentMemory
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a helpful assistant."),
MessagesPlaceholder("history"),
("human", "{input}"),
]
)
chain = prompt | model | StrOutputParser()
chain_with_history = RunnableWithMessageHistory(
chain,
lambda session_id: Neo4jAgentMemory(memory_client=client, session_id=session_id),
input_messages_key="input",
history_messages_key="history",
)
answer = await chain_with_history.ainvoke(
{"input": "I'm looking for running shoes"},
config={"configurable": {"session_id": "user-123"}},
)
RunnableWithMessageHistory reads the history with aget_messages() and writes
the turn back with aadd_messages() — both of which talk to Neo4j directly.
Use it directly
from langchain_core.messages import AIMessage, HumanMessage
history = Neo4jAgentMemory(memory_client=client, session_id="user-123")
await history.aadd_messages(
[
HumanMessage(content="I prefer spicy food"),
AIMessage(content="Noted — I'll remember that."),
]
)
messages = await history.aget_messages() # list[BaseMessage], oldest first
await history.aclear() # clears the session
Role mapping: HumanMessage ↔ "user", AIMessage ↔ "assistant",
SystemMessage ↔ "system", and ChatMessage(role="tool") ↔ "tool".
Messages with no text content (an AIMessage carrying only tool calls, say) are
skipped — memory stores text.
|
The sync surface ( |
Assemble a memory context block
The same object can render a context block for a hand-rolled prompt:
memory = Neo4jAgentMemory(
memory_client=client,
session_id="user-123",
include_short_term=True,
include_long_term=True,
include_reasoning=True,
max_messages=10,
)
variables = await memory.aload_memory_variables({"input": "restaurant recommendation"})
# {"history": "...", "context": "...", "preferences": [...], "similar_tasks": "..."}
await memory.asave_context(
{"input": "What's a good Thai restaurant?"},
{"output": "Based on your preferences, I recommend Thai Kitchen!"},
)
memory.memory_variables lists the keys the current include_* flags produce.
|
|
Retrieve from memory
Neo4jMemoryRetriever searches every memory layer and returns LangChain
Document objects sorted by similarity:
from neo4j_agent_memory.integrations.langchain import Neo4jMemoryRetriever
retriever = Neo4jMemoryRetriever(
memory_client=client,
session_id="user-123", # scopes message search
search_short_term=True, # messages
search_long_term=True, # entities + preferences
search_reasoning=True, # reasoning traces
k=10,
threshold=0.7,
)
docs = await retriever.ainvoke("spicy food preferences")
for doc in docs:
print(doc.metadata["type"], "-", doc.page_content)
Each document’s metadata["type"] is one of message, entity, preference or
trace, alongside id and similarity. Because it is a real BaseRetriever,
it composes with anything that takes one — create_retrieval_chain, a
retriever tool, LangSmith tracing — and ainvoke runs on the caller’s event
loop.
Pass session_id whenever you can: it scopes message search, and the hosted
NAMS backend requires it (its message search is conversation-scoped). Without
it on NAMS, the message layer is skipped with a warning rather than failing the
whole retrieval.
To let the agent decide when to search memory, hand the retriever to LangChain’s own tool wrapper instead of injecting context unconditionally:
from langchain_core.tools.retriever import create_retriever_tool
search_memory = create_retriever_tool(
retriever,
"search_memory",
"Search the user's memory: past messages, known entities, saved preferences.",
)
# agent = create_agent(model, tools=[search_memory], middleware=[...])
Record reasoning traces
Write the agent’s run into reasoning memory with a second middleware, so you can later ask "what did the agent do, and which entities did it touch?":
from typing import Any
from langchain.agents.middleware import AgentMiddleware, AgentState
from langchain_core.messages import AIMessage, HumanMessage
from neo4j_agent_memory import MemoryClient
from neo4j_agent_memory.schema.models import TraceOutcome
class ReasoningTraceMiddleware(AgentMiddleware):
"""Record each agent run as a reasoning trace."""
def __init__(self, client: MemoryClient, session_id: str) -> None:
super().__init__()
self.client = client
self.session_id = session_id
self.tools = []
async def abefore_agent(self, state: AgentState, runtime: Any) -> None:
task = next(
(m.text for m in reversed(state["messages"]) if isinstance(m, HumanMessage)),
"",
)
trace = await self.client.reasoning.start_trace(self.session_id, task)
self._trace_id = trace.id
async def aafter_model(self, state: AgentState, runtime: Any) -> None:
last = state["messages"][-1]
if not isinstance(last, AIMessage):
return
step = await self.client.reasoning.add_step(
self._trace_id,
thought=last.text or "(tool call only)",
action=", ".join(c["name"] for c in last.tool_calls) or "respond",
)
for call in last.tool_calls:
await self.client.reasoning.record_tool_call(
step.id,
call["name"],
call["args"],
)
async def aafter_agent(self, state: AgentState, runtime: Any) -> None:
await self.client.reasoning.complete_trace(
self._trace_id,
outcome=TraceOutcome(
success=True,
summary=state["messages"][-1].text[:200],
metrics={"model_calls": float(len(state["messages"]))},
),
)
Add it alongside the memory middleware:
agent = create_agent(
model,
tools=tools,
middleware=[
Neo4jMemoryMiddleware(client, session_id=session_id),
ReasoningTraceMiddleware(client, session_id=session_id),
],
)
Once traces exist, Neo4jMemoryMiddleware(include_reasoning=True) starts
surfacing similar past runs in the injected block. Without them that layer is
always empty, so leave include_reasoning=False until you record something.
See Audit Reasoning for the one-hop audit query
over TOUCHED edges.
Running on the hosted backend
The adapters tolerate the hosted NAMS backend’s narrower surface instead of raising:
| Call | On NAMS | Adapter behaviour |
|---|---|---|
|
supported |
full history and context |
|
supported |
entity documents and context block |
|
returns |
falls back to entity search |
|
|
|
|
|
|
|
needs a conversation id |
pass |
Switching backends is a settings change; the adapter code is identical:
from neo4j_agent_memory import MemoryClient, MemorySettings
# Hosted: reads MEMORY_API_KEY (and optionally MEMORY_WORKSPACE_ID)
settings = MemorySettings(backend="nams")
async with MemoryClient(settings) as client:
history = Neo4jAgentMemory(
memory_client=client,
session_id="user-123",
include_reasoning=False, # no trace search on NAMS
)
Best practices
Use one session per conversation
from datetime import datetime, timezone
# Good: unique, attributable session
session_id = f"user-{user_id}-{datetime.now(timezone.utc):%Y%m%d%H%M%S}"
# Avoid: one session shared by every user
session_id = "global"
For a policy rather than an ad-hoc string, use MemoryIntegration with a
SessionStrategy (PER_CONVERSATION, PER_DAY, PERSISTENT) — see
Adapters reference.
Stay on the async path
Every adapter’s async surface talks to Neo4j directly; the sync surface exists
only for genuinely synchronous callers and refuses to run inside an event loop.
Use ainvoke / astream on agents and chains, and the a* methods on the
adapters.
Cap what you inject
Neo4jMemoryMiddleware(client, session_id=session_id, max_items=5)
Neo4jAgentMemory(memory_client=client, session_id=session_id, max_messages=10)
Neo4jMemoryRetriever(memory_client=client, session_id=session_id, k=5)
Memory competes with the rest of the prompt for context; fewer, better items usually beat more.
Do not let memory break the agent
The middleware already degrades on unsupported layers, but storage errors propagate. Wrap the run if a memory outage must not take the agent down:
import logging
logger = logging.getLogger(__name__)
try:
result = await agent.ainvoke({"messages": [("user", question)]})
except Exception:
logger.warning("memory-backed agent run failed; retrying without memory")
plain = create_agent(model, tools=tools, system_prompt=system_prompt)
result = await plain.ainvoke({"messages": [("user", question)]})
Legacy: pre-1.0 LangChain
If you are maintaining an application built on LangChain 0.x, the classes the
earlier version of this guide used now live in the langchain-classic
distribution:
| Pre-1.0 import | 1.x location |
|---|---|
|
removed |
|
|
|
|
|
|
|
|
|
|
Neo4jAgentMemory can still back a classic ConversationBufferMemory through
its chat_memory= argument, because that argument takes a
BaseChatMessageHistory — which is exactly what Neo4jAgentMemory now is. New
code should use middleware or RunnableWithMessageHistory instead.