Adding a memory system¶
A memory system is added as a plugin: a separate Python package that the
framework discovers when it is installed in the same environment. You do not
edit the core package. The working reference is the example plugin in
memory_plugins/heams_memory_example/.
How the pieces fit:
- Your package defines a config class, a memory-system class and a
MemoryPlugindescriptor. - Its
pyproject.tomlexposes the descriptor through theheams.memory_systemsentry point, soheamsfinds it once the package is installed. - A YAML file under
configs/memory_systems/selects it by itstype. - A run config pairs that YAML with an agent and a benchmark, and
heams runevaluates it.
1. Import from the public API only¶
Import everything from heams.memory_systems.api. Other modules under
heams.memory_systems are internal and may change.
from heams.memory_systems.api import (
BaseMemoryConfig,
BaseMemorySystem,
MemoryEntry,
MemoryEventTarget,
MemoryOperationResult,
MemoryPlugin,
MemoryRuntimeContext,
render_tool_memory_entry,
)
2. Define the config¶
Subclass BaseMemoryConfig and give it a type literal. The type is how
YAML files select your system, so make it unique and namespaced (for example
acme.graph_memory, not graph_memory).
from typing import Literal
from pydantic import Field
class AcmeMemoryConfig(BaseMemoryConfig):
type: Literal["acme.memory"] = "acme.memory"
max_entries: int = Field(default=100, gt=0)
Every config inherits these fields:
| Field | Default | Meaning |
|---|---|---|
type |
required | Selects the plugin |
name |
agent_memory |
Display name used in telemetry |
top_k |
5 |
Default number of results for recall |
consolidation.enabled |
false |
When true, the framework calls consolidate() after priming and again when the sample finishes |
Unknown fields are rejected, so typos in YAML fail early. If your backend keeps
files on disk, override with_runtime_storage(storage_dir) to return a copy of
the config that points your storage at the per-sample storage_dir (the
HippoRAG, mem0 and milamem plugins do this).
3. Implement the memory system¶
Subclass BaseMemorySystem. Keep the keyword-only constructor arguments and
pass them to super().__init__; the framework uses them for storage and
event logging.
from pathlib import Path
from typing import Any
class AcmeMemorySystem(BaseMemorySystem):
config: AcmeMemoryConfig
def __init__(
self,
config: AcmeMemoryConfig,
*,
storage_dir: Path,
event_sink: MemoryEventTarget | None = None,
run_context: dict[str, Any] | None = None,
) -> None:
super().__init__(
config,
storage_dir=storage_dir,
event_sink=event_sink,
run_context=run_context,
)
self._entries: list[MemoryEntry] = []
def _retain_impl(
self, content: str, metadata: dict[str, Any]
) -> MemoryOperationResult:
self._entries.append(MemoryEntry(content=content, metadata=metadata))
return MemoryOperationResult(
value="Saved to memory.",
result={"write_count": 1},
)
def _recall_impl(self, query: str, top_k: int) -> MemoryOperationResult:
matches = [e for e in self._entries if query.casefold() in e.content.casefold()]
items = [render_tool_memory_entry(e) for e in matches[-top_k:]]
return MemoryOperationResult(
value=items,
result={
"returned_items": items,
"result_count": len(items),
"output_bytes": self._memory_size_proxy(items),
},
)
Methods you implement¶
| Method | Required | value to return |
|---|---|---|
_retain_impl(content, metadata) |
Yes | A short confirmation string |
_recall_impl(query, top_k) |
Yes | A list[str]; this is what the agent sees |
_delete_impl(query) |
No | A summary string. The default reports the operation as skipped |
_consolidate_impl(context, metadata) |
No | A summary string. The default reports the operation as skipped |
prime_memory_entries(entries) |
No | Nothing. Override it to load a benchmark's priming data in bulk; the default calls retain() for each entry |
close() |
No | Nothing. Release connections, files or servers |
Do not replace the public retain, recall, delete and consolidate
methods. They are what the agent's memory tools and the benchmark call, and the
base class wraps your _*_impl methods to time them, log an event for each
call, and turn an exception into an error event plus a failure message for
the agent. If you need to wrap one, for example to hold a lock as the Letta
plugin does, call super() so the timing and logging still happen.
You also do not implement set_retention_policy, which the Retention policy
benchmark uses. Set privacy_config in the memory-system YAML instead; the
framework then wraps your system in a privacy layer that provides it.
MemoryOperationResult¶
| Field | Meaning |
|---|---|
value |
What the caller receives (see the table above) |
status |
"success" (default), "skipped" (the operation is not supported) or "error" |
result |
Facts about the outcome, recorded in the event and read by the metrics (see below) |
payload |
Extra facts about the request, merged into the event's payload |
error |
An error message, when relevant |
result keys¶
Some keys are read by the framework's metrics and trace viewer; the rest are conventions that keep plugins consistent.
| Operation | Key | Used for |
|---|---|---|
| recall | returned_items |
Retrieval-quality metrics (hit@k, MRR); give the same list as value |
| recall | result_count (or returned_count) |
Efficiency metrics: results per recall |
| recall | output_bytes (or output_size_chars) |
Efficiency metrics: recall output size |
| any | summary or message |
Text shown for the event in execution traces |
| retain | write_count |
Convention: number of items written |
| delete | deleted_count |
Convention: number of items removed |
| consolidate | changed_count |
Convention: number of items merged or rewritten |
| any | supported |
Convention: False when the operation is not available |
The request side (content size, query, top_k, run context) is recorded by the
base class; you do not need to add it.
Helpers¶
| Helper | Use it to |
|---|---|
render_tool_memory_entry(entry) |
Format a MemoryEntry (content plus metadata) as a recall item |
self._memory_size_proxy(value) |
Measure a value consistently for output_bytes |
self._emit_memory_operation(...) |
Log a retain event yourself when prime_memory_entries loads data without calling retain() (as the HippoRAG, mem0 and Letta plugins do) |
4. Expose the plugin¶
Create a factory and a MemoryPlugin descriptor in your package:
def _create_memory(
config: BaseMemoryConfig, runtime: MemoryRuntimeContext
) -> BaseMemorySystem:
return AcmeMemorySystem(
config=config,
storage_dir=runtime.storage_dir,
event_sink=runtime.event_sink,
run_context=runtime.run_context,
)
plugin = MemoryPlugin(
type_name="acme.memory",
config_model=AcmeMemoryConfig,
create=_create_memory,
)
The framework calls create once per sample, with a fresh storage_dir, so
state never leaks between samples. type_name must match the config's type
and be unique among installed plugins; a duplicate stops discovery with an
error.
The contract is synchronous: each operation returns when it is done. Entry points load and run installed Python code; they are not a security sandbox, so only install plugins you trust.
Declare the entry point in your package's pyproject.toml:
Then install the package into the framework's environment, for example:
make setup (and uv sync) reset the environment to the lock file, so
reinstall your package afterwards. Plugins bundled in this repository are wired
as extras in python_package/pyproject.toml instead.
5. Add a config file¶
Create a YAML under configs/memory_systems/. backend_config is validated
against your config class:
Reference it from a run config's memory_system_config_file, as the configs
under configs/runs/ do.
6. Check it¶
memory list shows every discovered plugin type. memory test creates each
plugin in a temporary directory, retains a sentinel and recalls it, and reports
pass, failure or setup-required. setup-required means the plugin's
config cannot be built without settings (for example a model or an API key);
test such plugins with a real run config instead. memory test does not
exercise delete or consolidate, so cover those in your own tests.
To run your plugin on a benchmark, copy
configs/runs/examples/compare_memory_systems.yaml, point one experiment's
memory_system_config_file at your YAML, and run it as described in
Running experiments.
For a complete, working version of steps 1 to 6, including a real
_delete_impl and _consolidate_impl, read
memory_plugins/heams_memory_example/src/heams_memory_example/plugin.py.
7. Wrapping an existing memory system¶
Most plugins wrap a library or service that already stores and searches
memories. The bundled plugins show the patterns below; each row names the one to
read. HippoRAG (memory_plugins/heams_memory_hipporag/) is the smallest and the
best place to start; mem0 (memory_plugins/heams_memory_mem0/) adds more
configuration; Letta (memory_plugins/heams_memory_letta/) wraps a running
service.
| Pattern | What to do | Shown by |
|---|---|---|
| Library settings in the config | Pass the library's own settings through unchanged, or as typed sub-configs translated in one place | HippoRAG passes global_config as is; mem0 translates llm, embedder and vector_store in to_mem0_config() |
All state under storage_dir |
Override with_runtime_storage so every file, database and collection lives in the per-sample folder |
HippoRAG sets save_dir; mem0 sets the vector store path and a per-sample collection name, and its constructor puts the history database in storage_dir |
Create in __init__, release in close() |
Build the client in the constructor and release everything it holds in close() |
mem0 closes each underlying client; Letta deletes its agent and only logs a warning if that fails |
| Map retain and recall onto the library | _retain_impl calls the library's add or index and reports an honest write_count. _recall_impl calls its search and formats each hit with render_tool_memory_entry, keeping the library's metadata. Extra result keys are fine |
HippoRAG counts stored passages before and after indexing, formats hits with render_tool_memory_entry, and adds scores and graph_seeds; mem0 builds the same JSON itself and adds scores |
| Convert metadata the library cannot store | Translate metadata into the types the store accepts, and translate it back on recall | mem0 (_to_mem0_metadata and _rehydrate_mem0_metadata, for Chroma) |
| Delete and consolidate only when native | Use the library's own operation and report skipped when it cannot act; otherwise keep the defaults |
HippoRAG uses its native delete and reports skipped when nothing matches; mem0 keeps both defaults |
| Bulk priming | Override prime_memory_entries to load a whole conversation at once, using the ingest_* hints below, and log one retain event per group with _emit_memory_operation |
HippoRAG, mem0 and Letta |
| Wrapping a service | Share one client per process, fail at start-up if a setting or the service version is wrong, keep secrets out of the config by naming an environment variable, remove per-sample remote resources in close(), and wrap public methods (calling super()) when calls must not overlap |
Letta (Postgres URI from the variable named in pg_uri_env, version check, one agent per sample, a lock) |
Ingestion hints¶
Benchmarks that prime memory with a conversation (LoCoMo, LongMemEval and BEAM)
add ingest_* keys to each MemoryEntry's metadata, so a plugin can rebuild
the conversation instead of storing turns one by one:
| Key | Meaning |
|---|---|
ingest_group_id |
Turns that belong together, such as one session |
ingest_order |
Position of the turn within its group |
ingest_role |
The speaker's role, normally user or assistant |
ingest_speaker_name |
The speaker's name, when the benchmark has one (LoCoMo) |
ingest_timestamp_epoch |
When the turn happened, as a Unix timestamp |
ingest_scope_id |
The sample the turn belongs to |
They are hints for ingestion, not facts about the memory: HippoRAG drops them
from the metadata of the passages it builds while priming. Entries without
them should be stored one by one, which is what the default
prime_memory_entries does.