Most agent memory systems keep every interaction forever, and the index just bloats with noise over time. Lethe scores memories by importance, decays the ones that don't get used, reinforces the ones that do, and prunes the rest. The index stays small and retrieval stays fast even after weeks of continuous use.
Even at a smaller index than where Lethe naturally settles, a fixed-capacity FIFO baseline (evict oldest when full) forgets 2.5x as many durable facts as Lethe does: 53% vs 21% false-forget rate while Lethe keeps retrieval recall within about 1.4% of an unbounded "store everything" baseline. Full numbers and methodology in benchmark/RESULTS.md.
Install Lethe directly from PyPI:
pip install lethe-agentfrom lethe import MemoryStore, DecayConfig
store = MemoryStore(
backend="sqlite", # durable across restarts
decay_config=DecayConfig(), # every tunable lives here
)
store.remember("client's fiscal year ends in March", session_id="s1", tags=["fact"])
results = store.recall("when does fiscal year end?", k=5)
for item in results:
print(item.content, "score:", round(item.importance_score, 3))No external API calls, no model downloads: this runs out of the box with a
bundled hash-based fake embedder. Swap in a real embedding model by passing
anything with an embed(text) -> list[float] method.
For a real sentence-transformers-backed embedder, install the optional extra and plug it in:
pip install "lethe-agent[sentence-transformers]"from lethe import MemoryStore, DecayConfig
from lethe.integrations.sentence_transformers_embedder import SentenceTransformersEmbedder
embedder = SentenceTransformersEmbedder(model_name="all-MiniLM-L6-v2")
store = MemoryStore(decay_config=DecayConfig(), embedder=embedder)The bundled HashFakeEmbedder caps paraphrase similarity around 0.4-0.7
cosine; all-MiniLM-L6-v2 puts true paraphrases above 0.7. The benchmark
runs entirely against the fake embedder for determinism (no model
downloads), but real workloads benefit substantially from a real model.
Vector-store-backed agent memory today mostly follows one pattern: embed everything, dump it in a vector store, retrieve top-k by similarity. Run it for weeks instead of minutes and two things go wrong. The index grows forever, so old and superseded facts start competing with current ones during search. And there's no notion of importance: "it's raining today" and "the client's fiscal year ends in March" get stored identically, with no way to tell them apart later.
Lethe treats forgetting as a feature, not a missing one.
Every memory gets an initial score on capture, based on recency, source type, and any explicit feedback. Retrieving a memory reinforces it: bumps the score, increments the access count, refreshes last-accessed time. Left alone, scores decay on an exponential half-life. A daily consolidation pass promotes short-term memories that earned their keep into long-term storage, demotes long-term memories that didn't, merges near-duplicates, and prunes anything that's decayed past the cold-storage grace period.
Every deletion gets written to an append-only Forget Log: the score, age, and last-access time it had at the moment it was removed. Silent data loss is the thing this is meant to avoid; forgetting should be something you can inspect after the fact, not something that just happens.
All the tunable constants: half-life, thresholds, weights, grace period,
live in one DecayConfig object. Nothing is hardcoded elsewhere.
Averaged over 3 random seeds, default workload (full numbers here):
| Metric | Naive | FIFO (100) | LRU (100) | LFU (100) | TTL (7 d) | LIFO (100) | Lethe |
|---|---|---|---|---|---|---|---|
| Final store size | 184 ± 1 | 100 | 100 | 100 | 33 ± 1 | 100 | 143 ± 2 |
| Held-out recall @ 1 | 0.800 | 0.467 ± 0.072 | 0.711 ± 0.047 | 0.778 ± 0.016 | 0.189 ± 0.016 | 0.800 | 0.789 ± 0.016 |
| Held-out recall @ 5 | 0.900 | 0.544 ± 0.096 | 0.800 ± 0.027 | 0.878 ± 0.016 | 0.200 ± 0.027 | 0.922 ± 0.016 | 0.956 ± 0.016 |
| False-forget rate | 0.200 | 0.533 ± 0.072 | 0.300 ± 0.047 | 0.222 ± 0.016 | 0.811 ± 0.016 | 0.200 | 0.211 ± 0.016 |
| Mean latency (ms) | 2.21 ± 0.04 | 1.81 ± 0.06 | 1.81 ± 0.04 | 1.80 ± 0.05 | 0.87 ± 0.03 | 1.79 ± 0.04 | 1.99 ± 0.06 |
Each policy's marker position reflects the combined efficiency/quality trade-off: lower-left is better (smaller index, fewer forgotten facts).
False-forget rate
0.10 0.20 0.30 0.40 0.50 0.60 0.70 0.80 0.90
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
Final store size │ │
│ │
50 ────── │ ★ TTL (33, 0.81) │
│ │
90 ────── │ │
│ │
100 ────── │ ★ LIFO ★ LFU ★ FIFO ★ LRU │
│ (100, (100, (100, (100, │
│ 0.20) 0.22) 0.53) 0.30) │
110 ────── │ │
│ │
130 ────── │ │
│ │
140 ────── │ ★ Lethe (143, 0.21) │
│ (default) │
150 ────── │ │
│ │
180 ────── │ ★ Naive (184, 0.20) │
│ │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
Reading the chart:
- Lower-left is better. TTL is the worst (high false-forget despite smallest index). FIFO is the worst bounded policy on this workload.
- Lethe lives in the sweet spot: 22% smaller than Naive with effectively the same false-forget rate.
- LIFO and Naive happen to coincide on false-forget in this workload because the durable facts were captured first (see "Which policy should I use?" below for the caveat).
Higher is better. Naive and LIFO are tied for the top; TTL is the worst.
Policy Recall@1 Bar (each █ = 0.05 R@1)
─────────────────────────────────────────────────────────────────────────────
Naive 0.800 ████████████████
LIFO 0.800 ████████████████
Lethe 0.789 ███████████████▏
LFU 0.778 ███████████████
LRU 0.711 ██████████████▍
FIFO 0.467 █████████▍
TTL 0.189 ███▊
Lower is better. Naive and LIFO tie for the lowest; TTL is by far the worst.
Policy False-forget Bar (each █ = 0.05 false-forget)
─────────────────────────────────────────────────────────────────────────────
TTL 0.811 ████████████████▏
FIFO 0.533 ██████████▌
LRU 0.300 ██████
LFU 0.222 ████▌
Lethe 0.211 ████▏
LIFO 0.200 ████
Naive 0.200 ████
The benchmark also ran three labeled sub-scenarios. The headlines:
- Durable-fact-heavy (every fact queried every day): LRU/LFU/LIFO all match Naive (R@1 0.800). Lethe wins on R@5 (0.947) and has the smallest index (118.7 ± 3.4).
- Recency-biased (queries focus on recently captured items): LIFO and Naive tie at 0.800 R@1 because durable facts are old. LRU suffers (0.622) — its signal is noisy when access recency doesn't correlate with importance.
- High-frequency-skew (one fact queried 10×/day): LFU matches Lethe (0.789 R@1). LFU is the right call for hot-item workloads.
One caveat worth stating plainly: these numbers come from a synthetic benchmark using a lightweight hash-based embedder for deterministic testing, not a production embedding model. The relative ordering between policies is what I'd stand behind; treat the absolute numbers as directional. Sample size is n=3 seeds per (policy, workload) — see RESULTS.md for the full stability report and the sub-scenario metrics that exceed CV=20%.
Run it yourself:
python benchmark/run_benchmark.py --seeds 3 --fifo-max 100capture → score → [reinforce | decay] → consolidate → retrieve → forget
↑ │
└────────── reinforcement ──────────────┘
MemoryStore orchestrates everything: the backend, the decay config, the
embedder, the clock, and the Forget Log. DecayConfig is the single source
of truth for tunable behavior. StorageBackend is a small interface with two
implementations: InMemoryBackend for speed and tests, SQLiteBackend for
anything that needs to survive a restart. Embedder is a protocol with one
default (HashFakeEmbedder, dependency-free and deterministic): plug in a
real embedding model the same way. ForgetLog follows the same in-memory /
SQLite pattern.
Full design rationale, the decay math, and the lifecycle rules are in DESIGN.md.
For a day-by-day walkthrough of the 30-day session, watching memory grow, decay, and get pruned as it happens:
python examples/long_running_agent_demo.pyPauses at a few key days so there's time to read what happened. Add
--no-pause to run it straight through.
lethe.integrations.langgraph_adapter.LetheMemoryNode wraps a MemoryStore
as plain callables shaped for LangGraph nodes:
from lethe import MemoryStore, DecayConfig
from lethe.integrations.langgraph_adapter import LetheMemoryNode
store = MemoryStore(decay_config=DecayConfig())
memory = LetheMemoryNode(store, k=5)
# graph.add_node("recall", memory.recall)
# graph.add_node("remember", memory.remember)LangGraph isn't a dependency of the core library: this is just a reference integration. Ignore it if you're not using LangGraph.
Clone the repository to run the complete test and benchmark suites:
git clone https://github.com/Fqih/lethe.git
cd lethe
pip install -e ".[dev,benchmark]"
pytestCovers capture, scoring, decay math, retrieval reinforcement, consolidation (promotion, demotion, dedup), the Forget Log's zero-gaps invariant, parity between the in-memory and SQLite backends, and the LangGraph adapter.
There's no real LLM call anywhere in the core library: embedding goes
through whatever Embedder you pass in, and the demo/benchmark default to
the bundled fake so everything runs offline. The default backends (a dict,
SQLite) are fine for thousands of items; past that you'd want a real vector
index like FAISS or Chroma, wrapped as a StorageBackend. And it's a
library, not a service: there's no GUI here.
Early and still rough in places. Built to explore what selective memory could look like for long-running agents, not a hardened production library yet. Issues and PRs welcome.
Lethe ships with six retention policies. The benchmark (benchmark/RESULTS.md) ran all six across four workloads (default, durable_heavy, recency_biased, high_freq_skew) over 3 seeds. Every recommendation below is backed by a number in the tables there.
What it does: items get an initial importance score on capture, decay over time on an exponential half-life, get reinforced when retrieved, and the daily consolidation pass promotes durable items to long-term, demotes decayed items to cold, merges near-duplicates, and prunes cold items past their grace period.
Recommended for: any general-purpose agent. This is the default because the benchmark shows it ties Naive on recall while staying ~22% smaller and ~10% faster (aggregate workload: 0.789 R@1 vs Naive's 0.800, 143-item index vs 184).
Evidence (RESULTS.md aggregate table): Lethe 0.789 ± 0.016 R@1, 0.211 ± 0.016 false-forget, 1.99 ± 0.06 ms latency.
What it does: evicts the oldest-captured item first.
Recommended against for general use. The benchmark makes the case clearly: at a 100-item cap (smaller than Lethe's natural 143), FIFO forgets 53% of durable facts while Lethe forgets 21% — a 2.5× gap in favor of the larger index. Even on the durable_heavy workload where every fact is queried constantly, FIFO still loses 49% of facts to eviction.
Where it might be OK: ephemeral caches where age genuinely correlates with irrelevance and you don't have access signals.
Evidence: 0.467 ± 0.072 R@1 (aggregate), 0.511 ± 0.058 R@1 (durable_heavy), 0.533 ± 0.072 false-forget (aggregate).
What it does: evicts the least-recently-accessed item first.
Recommended for: read-heavy caches where hot items are queried repeatedly (recent accesses matter more than creation order). Outperforms FIFO on every workload because it uses a real signal the workload produces.
Caveat: under recency-biased workloads where everything gets touched occasionally, the LRU signal is noisy. R@1 drops from 0.711 (aggregate) to 0.622 (recency_biased).
Evidence: 0.711 ± 0.047 R@1 (aggregate), 0.622 ± 0.047 R@1 (recency_biased), 0.300 ± 0.047 false-forget (aggregate).
What it does: evicts the least-frequently-accessed item first.
Recommended for: workloads with a stable set of "hot" items that get queried over and over (e.g., a knowledge base for a specific domain). LFU matches Naive on R@5 under high_freq_skew (0.954 vs Naive's 0.958) — frequency tracking is the right signal when a few items dominate the access pattern.
Caveat: ties broken by age, so cold items in the long tail of the access distribution still get evicted. Worst-case false-forget of the bounded policies that don't filter on time.
Evidence: 0.778 ± 0.016 R@1 (aggregate), 0.789 ± 0.016 R@1 (high_freq_skew), 0.222 ± 0.016 false-forget (aggregate).
What it does: hard expiry after max_age_seconds, regardless of
access count or importance.
Recommended for: compliance-sensitive deployments where memory must be gone after a fixed time no matter how important it scored (e.g., GDPR-style "right to be forgotten" with a hard deadline). The benchmark shows the cost; the use case is explicitly time-bounded.
Not recommended for: any workload with durable facts older than the TTL window. The benchmark uses a 7-day TTL on a 30-day simulation; every fact older than a week gets dropped, and TTL ends up with the worst false-forget rate (0.81 aggregate).
Evidence: 0.189 ± 0.016 R@1 (aggregate), 0.244 ± 0.057 R@1 (durable_heavy), 0.811 ± 0.016 false-forget (aggregate).
What it does: evicts the newest-captured item first.
Documented as the outlier/niche case, not a general recommendation. The benchmark includes it because it's the natural counterpart to FIFO, but LIFO's behavior is path-dependent on capture order: it matches Naive on the default workload because durable facts were captured first (and LIFO preserves older items), but it would do the opposite on a workload where new durable facts arrive late. Don't reach for it unless you've verified your workload's capture order.
Evidence: 0.800 ± 0.000 R@1 (aggregate — looks great, by accident of the benchmark's capture order); comparable false-forget to Naive on aggregate. The recency_biased workload shows it can match Naive too. The benchmark can't construct a workload that disadvantages LIFO without artificially reordering captures, so the apparent win is real but circumstantial.
Not a policy — a wrapper around any of the above. Pin specific items (by id, by tag, or any other predicate) so they survive eviction regardless of what the base policy decides. Useful for "this user's stated identity must never be evicted" or "these configuration values are permanent."
Evidence: integration test in
tests/test_store_retention.py::test_pinned_overlay_protects_pinned_items_through_consolidation
— a pinned item survives multiple consolidation passes even
under tight capacity pressure.
| Workload | First choice | Avoid |
|---|---|---|
| General-purpose agent | Lethe | TTL |
| Read-heavy knowledge base | LFU | FIFO |
| Compliance with a hard deadline | TTL | (any other) |
| Hot items dominate the access pattern | LFU | LIFO, FIFO |
| Long-tail access with frequent warm-up | LRU | TTL |
| Items must never be evicted, full stop | PinnedOverlay(any) | — |
The defaults of MemoryStore(decay_config=DecayConfig()) give you
Lethe. If you know your workload, swap in a different
retention_policy= and (for capacity-based policies) a
capacity=. The benchmark script in benchmark/run_benchmark.py
runs all of them against your scenario in one shot — copy it as a
starting point for tuning.
MIT: see LICENSE.
