Long-term Memory

In shortHow to build agents that learn and remember across conversations using semantic memory storage and retrieval.

  • 10 min read
  • 12 sections
  • Updated
  • v0.10.0
  • Markdown

Your agent needs to remember facts across conversations: a user’s preferences, resolved questions, learned skills. The memory store makes this possible by providing a semantic, searchable database of memories that persists independently of the thread. It works alongside the checkpointer to create a two-level recall system: fast thread-specific state and cross-thread semantic knowledge.

Why long-term memory matters

As an agent serves multiple users and conversations, it accumulates context that is too specific or expensive to carry in every message:

  • A user mentioned their timezone once; future conversations should know it.
  • You discovered an error-handling workaround; later conversations should learn from it.
  • You extracted a fact from a document; it should be available without re-uploading.

The checkpointer solves this within a thread (conversation continuity), but it cannot share knowledge across threads. The memory store adds that cross-thread layer, enabling agents to learn from experience and personalize behavior over time.

How it works: memory store vs checkpointer

The checkpointer saves your full agent state (messages, context, custom fields) to resume a thread. The memory store saves individual semantic memories you want to recall across threads. They solve different problems:

Aspect Checkpointer Memory store
Scope One thread Cross-thread, cross-user
Content Full AgentState snapshot Individual semantic records
Lifetime Until thread is deleted Until explicitly deleted
Access By thread_id By semantic similarity
Use case Conversation continuity Facts, preferences, knowledge

For example, if a user says “I’m in UTC-5”, the checkpointer remembers it for that thread. The memory store lets you save it (to find later) and retrieve it in any future thread when the agent asks “What timezone is the user in?”

How the agent uses memory

The LLM doesn’t passively receive memories (except in preload mode); it calls a tool to work with them. There are two ways to get these tools, and they differ:

Setup Tools the model sees
Agent(memory=MemoryConfig(...)) in postload mode user_memory_tool (action="search" or "remember", with text) and, if agent memory is enabled, the read-only agent_memory_tool (query)
MemoryIntegration(...).tools A single memory_tool with action="store", "search", "update" or "delete"

With user_memory_tool the model never supplies memory ids; the memory layer manages identity and deduplication.

The memory_tool flow looks like this:

plaintext
LLM thinks: "I should remember this timezone"
       ↓
Calls: memory_tool(action="store", content="User is in UTC-5", memory_key="user_timezone")
       ↓
Result: {"status": "scheduled", "action": "store"}
       ↓
Future thread: LLM calls memory_tool(action="search", query="user timezone")
       ↓
Result: JSON list of matches, retrieved by semantic similarity, not keyword lookup

Deduplication (memory_tool): if you call store with the same memory_key, the old record is updated rather than duplicated. When no key is given, a similarity check (score of 0.95 or higher) skips near-identical content.

Non-blocking writes: memory writes run in the background via BackgroundTaskManager, so they never delay the agent’s response. A search first waits for pending writes to finish.

Retrieval modes: when do memories reach the LLM?

The framework supports three strategies (no_retrieval, preload, postload) for when the LLM sees memories. MemoryConfig defaults to postload; MemoryIntegration defaults to no_retrieval. Choose based on how much memory context you want injected, and whether you prefer the agent to drive retrieval:

No retrieval (default)

The LLM cannot read past memories. With MemoryIntegration, it can still write them through memory_tool. With Agent(memory=MemoryConfig(retrieval_mode="no_retrieval")), no memory tools are exposed.

When to use: Your agent is mostly stateless, or memories are only for logging/analytics.

Pros Cons
Fast; no store queries No cross-conversation learning
Simple; minimal overhead Agent can’t retrieve past context

Preload

Relevant memories are retrieved automatically and injected as a system message before the LLM runs.

How it works:

  1. The user’s latest message is used as the search query (override it with query_builder on MemoryIntegration).
  2. Memories matching that query (by semantic similarity) are fetched.
  3. They are injected as a system message that starts with [Long-term Memory Context].
  4. The LLM then sees the conversation with these memories in context.

With Agent(memory=...) this happens inside the Agent before each model call. With MemoryIntegration, memory.wire(...) adds a separate preload node.

When to use: Your agent should proactively use past knowledge. Ideal for retrieval-augmented generation (RAG), customer support, or personalization where the agent should always have relevant context available.

Pros Cons
Agent sees memories by default Extra latency (preload query)
Good for RAG patterns More tokens injected per message
Transparent to agent Noisy if memories are not well-curated

Postload

The LLM decides when to retrieve memories by calling the memory tool with action="search" itself.

How it works: The memory tool is offered alongside your other tools. When the agent wants to check past context, it calls the tool with a query, receives results, and decides what to do with them.

When to use: Your agent should selectively retrieve memories only when needed. Ideal for conversational agents that can ask “Do I know about this user?” or agents that are memory-efficient and don’t need context injected automatically.

Pros Cons
Agent controls memory use More tool calls (latency)
Avoids injecting irrelevant context Requires the agent to know to retrieve
Efficient Agent might forget to retrieve

Trade-off table

Aspect No retrieval Preload Postload
Memory injection Never Automatic (every message) On-demand (agent calls tool)
Latency Lowest Higher (one search per message) Variable (depends on agent)
Best for Stateless agents RAG, personalization Selective retrieval
Token cost Lowest Highest (if many memories match) Variable

Memory scoping: user vs agent memory

You can partition memories into scopes so different agents or users see different memories:

Scope Who can access Use case
User memory (UserMemoryConfig, enabled by default) The LLM can search and write Facts about a specific user (timezone, preferences, history)
Agent memory (AgentMemoryConfig, disabled by default) The LLM can only search Shared knowledge scoped by agent_id or app_id (domain facts, workarounds)

Example: A customer support agent remembers the customer’s billing cycle (user memory). Your own code seeds agent memory with known workarounds, and the model reads them through agent_memory_tool. The model has no tool to write agent memory.

Both scopes can share one backing store or each take their own store, and they are queried separately.

Choosing: memory tools (postload) vs preload

The most important decision is how the agent accesses memory. This determines your latency, token usage, and agent behavior.

Use postload (memory tools) if:

  • The agent should decide when to recall, not every message.
  • You want to minimize token overhead and latency.
  • Your agent is sophisticated enough to know when to ask “Do I have context on this?”
  • Example: A conversational assistant that occasionally checks past notes, but doesn’t need proactive context.

Use preload if:

  • Memories should always be available without the agent asking.
  • You’re building RAG and expect relevant memories to match most queries.
  • You want the simplest behavior: the agent always sees relevant context.
  • Token cost is secondary to ensuring the agent has context.
  • Example: A customer support bot that needs a user’s history for every message.

Use no_retrieval if:

  • You don’t need cross-conversation memory at all, only in-thread state.
  • Memories are for analytics or audit, not agent behavior.
  • You want the fastest, simplest setup.

Available store backends

The two main options are Qdrant (vector database you host) and Mem0 (managed service):

Store Hosting Embeddings Good for
QdrantStore Local or cloud Qdrant Your choice (OpenAI, Google, custom) Full control, self-hosted
Mem0Store Mem0 managed service Mem0-provided Low ops, Mem0 handles scaling

Both use semantic similarity search, so searches match meaning, not keywords. A query like “What’s my timezone?” will find a memory “I’m UTC-5” even though the words are different.

Setting up Qdrant locally

Terminal
pip install "10xgraph[qdrant,openai]"
Python
from tenxgraph.storage.store import (
    create_local_qdrant_store,
    OpenAIEmbedding,
)

store = create_local_qdrant_store(
    collection="agent-memories",
    path="./memory_data",
    embedding=OpenAIEmbedding(),  # requires OPENAI_API_KEY
)

This creates a persistent database on disk. For production, use create_cloud_qdrant_store(url=..., api_key=..., embedding=...) for Qdrant Cloud, or create_remote_qdrant_store(host=..., port=..., embedding=...) for a server you run. GoogleEmbedding is also available. For Mem0, install the mem0 extra and use create_mem0_store.

Implementation patterns

Pattern 1: With the high-level Agent class

This is the simplest approach. Pass memory to Agent and the framework registers the memory tools and prompt. You still build the graph and pass the store to compile:

Terminal
pip install "10xgraph[qdrant,openai,google-genai]"
Python
from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState, Message
from tenxgraph.storage.store import (
    MemoryConfig,
    OpenAIEmbedding,
    create_local_qdrant_store,
)
from tenxgraph.utils import END, tool

store = create_local_qdrant_store(
    collection="user-memories",
    path="./memory_data",
    embedding=OpenAIEmbedding(),  # requires OPENAI_API_KEY
)


@tool
def search_web(query: str) -> str:
    """Search the web for information."""
    return f"No results for {query}"


tool_node = ToolNode([search_web])

agent = Agent(
    model="google/gemini-2.5-flash",
    tool_node=tool_node,
    memory=MemoryConfig(
        store=store,
        retrieval_mode="postload",  # the model calls user_memory_tool when needed
        limit=5,                    # max memories per search
    ),
)


def route(state: AgentState) -> str:
    last = state.context[-1] if state.context else None
    if last and last.role == "assistant" and last.tools_calls:
        return "TOOL"
    return END


graph = StateGraph()
graph.add_node("MAIN", agent)
graph.add_node("TOOL", tool_node)
graph.add_conditional_edges("MAIN", route, {"TOOL": "TOOL", END: END})
graph.add_edge("TOOL", "MAIN")
graph.set_entry_point("MAIN")
app = graph.compile(store=store)

result = app.invoke(
    {"messages": [Message.text_message("What's my timezone?")]},
    config={"thread_id": "t1", "user_id": "user-42"},
)

The Agent class automatically:

  1. Adds memory instructions to the system prompt (unless inject_system_prompt=False).
  2. Registers user_memory_tool (and agent_memory_tool if enabled) on the ToolNode, in postload mode only.
  3. Scopes memories by user_id from the config.

In postload mode you must give the Agent a ToolNode (or the name of a ToolNode graph node); otherwise the framework raises RuntimeError.

Pattern 2: With StateGraph and MemoryIntegration

For more control (custom routing, multiple nodes), use MemoryIntegration with StateGraph:

Python
from tenxgraph.core.graph import Agent, StateGraph, ToolNode
from tenxgraph.core.state import AgentState
from tenxgraph.storage.store import (
    MemoryIntegration,
    OpenAIEmbedding,
    create_local_qdrant_store,
)
from tenxgraph.utils import END, tool

store = create_local_qdrant_store(
    collection="agent-memories",
    path="./memory_data",
    embedding=OpenAIEmbedding(),
)

memory = MemoryIntegration(
    store=store,
    retrieval_mode="preload",  # inject memories before the LLM runs
    limit=5,
)


@tool
def search_web(query: str) -> str:
    """Search the web for information."""
    return f"No results for {query}"


# memory.tools is [memory_tool], so the model can still write memories
tool_node = ToolNode([search_web, *memory.tools])

agent = Agent(
    model="google/gemini-2.5-flash",
    system_prompt=[{"role": "system", "content": memory.system_prompt}],
    tool_node="TOOLS",
)


def route(state: AgentState) -> str:
    last = state.context[-1] if state.context else None
    if last and last.role == "assistant" and last.tools_calls:
        return "TOOLS"
    return END


graph = StateGraph()
graph.add_node("AGENT", agent)
graph.add_node("TOOLS", tool_node)
graph.add_edge("TOOLS", "AGENT")
graph.add_conditional_edges("AGENT", route, {"TOOLS": "TOOLS", END: END})

# Adds the preload node and sets the entry point
memory.wire(graph, entry_to="AGENT")

app = graph.compile(store=store)

Invoke it as in Pattern 1. Key differences:

  • You build the graph yourself, so you have full control over nodes and edges.
  • memory.tools gives you the single memory_tool to add alongside your own tools.
  • memory.wire(graph, entry_to="AGENT") sets the entry point and, in preload mode, inserts the preload node.
  • memory.system_prompt is a string with the LLM instructions for the configured mode.

MemoryConfig options

For Agent(memory=...), customize memory behavior:

Python
from tenxgraph.storage.store import (
    AgentMemoryConfig,
    MemoryConfig,
    UserMemoryConfig,
)

config = MemoryConfig(
    store=store,                       # The vector store
    retrieval_mode="postload",         # "no_retrieval" | "preload" | "postload" (default "postload")
    limit=5,                           # Max memories per search (default 5)
    score_threshold=0.0,               # Min similarity (default 0.0)
    max_tokens=None,                   # Optional: limit tokens in injected memories
    inject_system_prompt=True,         # Auto-add memory instructions
    user_memory=UserMemoryConfig(
        enabled=True,                  # User-scoped memories the LLM can search and write
        memory_type="episodic",
        category="general",
        limit=5,
    ),
    agent_memory=AgentMemoryConfig(
        enabled=True,                  # Read-only for the LLM (default False)
        memory_type="semantic",
        agent_id="my-agent",
    ),
)

How memories are written and retrieved

With MemoryIntegration, the LLM writes memories by calling memory_tool:

Python
memory_tool(
    action="store",
    content="User is in UTC-5 timezone",
    memory_key="user_timezone",          # Key for deduplication
    memory_type="episodic",
    category="preferences",
)
# Returns {"status": "scheduled", "action": "store"}. If "user_timezone"
# already existed, it is updated.

It searches (in postload mode) by calling:

Python
memory_tool(action="search", query="What timezone is the user in?")
# Returns JSON: a list of matches with content and score

With Agent(memory=...) the calls are user_memory_tool(action="remember", text="...") and user_memory_tool(action="search", text="...").

In preload mode, the framework does the search automatically and injects the results as a system message starting with [Long-term Memory Context].

Accessing memory in the REST API

When a store is configured in 10xgraph.json, the server exposes memory endpoints:

Terminal
POST   /v1/store/memories              # Store a new memory
POST   /v1/store/memories/list         # List memories for a user
POST   /v1/store/search                # Search memories by query
POST   /v1/store/memories/{id}         # Get a memory
PUT    /v1/store/memories/{id}         # Update a memory
DELETE /v1/store/memories/{id}         # Delete a memory
POST   /v1/store/memories/forget       # Forget memories matching filters

See REST API: Memory store for request/response schemas.

Memories are scoped by the authenticated user’s user_id (anonymous when there is none), so users do not see each other’s memories.

Configuring memory in 10xgraph.json

Point the API server to a memory store:

JSON
{
  "agent": "graph:app",
  "store": "graph.dependencies:my_memory_store"
}

The store key expects a module:attribute path pointing to a BaseStore instance. The server uses it for the /v1/store endpoints.


Next steps

Last updated for v0.10.0Edit this page on GitHubReport an issue