Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

14 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lethe

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.

Benchmark chart: store size and recall over a 365-day simulated agent session

Over a year-long session with 5 random seeds and 5 workloads (including a bursty workload that pushes capacity-based policies into real overflow pressure), a fixed-capacity FIFO baseline (evict oldest when full) forgets 2.5x as many durable facts as Lethe at half the index size: 0.507 vs 0.207 false-forget rate. Lethe keeps retrieval recall within 0.7% of an unbounded "store everything" baseline. Full numbers and methodology in benchmark/RESULTS.md.

Quickstart

Install Lethe directly from PyPI:

pip install lethe-agent
from 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.

The problem

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.

How it works

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.

Benchmark: 365 simulated days, 5 seeds, 5 workloads

Averaged over 5 random seeds, default workload (the same one used in the 0.1.1 release — comparison numbers below are means ± std across seeds):

Aggregate table — all policies side by side (default workload)

Metric Naive FIFO (100) LRU (100) LFU (100) TTL (7 d) LIFO (100) Lethe
Final store size 1677 ± 13 100 100 100 32 ± 1 100 1068 ± 18
Held-out recall @ 1 0.800 0.493 ± 0.071 0.587 ± 0.026 0.793 ± 0.013 0.247 ± 0.050 0.800 0.793 ± 0.013
Held-out recall @ 5 0.841 ± 0.008 0.577 ± 0.029 0.667 ± 0.029 0.882 ± 0.022 0.258 ± 0.011 0.926 ± 0.015 0.962 ± 0.004
False-forget rate 0.200 0.507 ± 0.071 0.413 ± 0.026 0.207 ± 0.013 0.753 ± 0.050 0.200 0.207 ± 0.013
Mean latency (ms) 24.96 ± 1.92 2.18 ± 0.04 2.18 ± 0.05 2.16 ± 0.09 0.77 ± 0.02 2.17 ± 0.07 16.57 ± 1.40

Index-size growth from 30 days to 365 days: Naive 184 → 1677 (~9x), Lethe 143 → 1068 (~7x). Naive scales linearly with workload volume; Lethe grows more slowly because the consolidation pipeline demotes low-signal items out of the hot index.

Visual comparison — final store size vs false-forget rate

Each policy's marker position reflects the combined efficiency/quality trade-off: lower-left is better (smaller index, fewer forgotten facts).

Store-size vs false-forget scatter for the 365-day aggregate workload

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 is the only policy that hits the sweet spot at scale: matches Naive on recall (R@1 0.793 vs 0.800) with 36% smaller index and 0.207 false-forget (within 0.7% of Naive's 0.200).
  • 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).

Visual comparison — recall @ 1 by policy (default workload)

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.793      ███████████████▉
LFU       0.793      ███████████████▉
LRU       0.587      ███████████▊
FIFO      0.493      █████████▊
TTL       0.247      ████▉

Visual comparison — false-forget rate by policy (default workload)

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.753          ███████████████▎
FIFO      0.507          ██████████
LRU       0.413          ████████▎
LFU       0.207          ████▏
Lethe     0.207          ████▏
LIFO      0.200          ████
Naive     0.200          ████

Sub-scenario results (full tables in benchmark/RESULTS.md)

The benchmark also ran four labeled sub-scenarios. The headlines:

  • Durable-fact-heavy (every fact queried every day): LRU/LFU/LIFO all match Naive exactly (R@1 0.799, false-forget 0.200). Lethe is 49% smaller than Naive (865 vs 1693) and matches everyone on R@1 with the highest R@5 (0.963).
  • Recency-biased (queries focus on recently captured items): LIFO still ties Naive (0.800 R@1) because durable facts are old; LRU drops to 0.621 — its signal is noisy when access recency doesn't correlate with importance. Lethe takes a small hit on R@1 (0.753) but jumps to 0.911 R@5.
  • High-frequency-skew (one fact queried 10×/day): The hot fact dominates retrieval — LFU/Lethe/LIFO all reach 0.96+ R@1. Some seeds have a degenerate tie at top-1 (multiple facts with near-equal cosine to the hot query), pushing R@1 std to ~0.38; R@5 is stable at 0.71-0.99.
  • Bursty (10% of days are burst days with 50 captures + 10 questions): the new stress workload. Naive's index swells to 3272 items (largest of any workload). FIFO's bounded 100-item cap collapses under the burst pressure (0.467 R@1, 0.533 false-forget); Lethe absorbs the burst intact (0.800 R@1, 0.200 false-forget at index 2214).

Full tables with ± std for every cell are in benchmark/RESULTS.md.

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=5 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 --days 365 --seeds 5

Architecture

capture → 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.

A longer demo

For a day-by-day walkthrough of a 30-day session, watching memory grow, decay, and get pruned as it happens:

python examples/long_running_agent_demo.py

Pauses at a few key days so there's time to read what happened. Add --no-pause to run it straight through.

Using it with LangGraph

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.

Tests

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]"
pytest

Covers 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.

What this isn't

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.

Status

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. See CHANGELOG.md for what changed between releases.

Which policy should I use?

Lethe ships with seven retention policies. The benchmark (benchmark/RESULTS.md) ran all seven across five workloads (default, durable_heavy, recency_biased, high_freq_skew, bursty) over 5 seeds at 365 simulated days. Every recommendation below is backed by a number in the tables there.

Lethe (default — score-decay with consolidation)

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 ~36% smaller (aggregate workload: 0.793 R@1 vs Naive's 0.800, 1068-item index vs 1677).

Evidence (RESULTS.md aggregate table): Lethe 0.793 ± 0.013 R@1, 0.207 ± 0.013 false-forget, 16.57 ± 1.40 ms latency.

FIFOPolicy

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, FIFO forgets 51% of durable facts while Lethe forgets 21% — a 2.5× gap in favor of the larger index. On the bursty workload (where every policy sees ~36 burst days in 365 days), FIFO collapses to 0.467 R@1 and 0.533 false-forget. Even on the durable_heavy workload where every fact is queried constantly, FIFO still loses 55% 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.493 ± 0.071 R@1 (aggregate), 0.447 ± 0.072 R@1 (durable_heavy), 0.507 ± 0.071 false-forget (aggregate).

LRUPolicy

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.587 (aggregate) to 0.621 (recency_biased) — but on the bursty workload the warm-up behavior keeps LRU close to its aggregate R@1 (0.595).

Evidence: 0.587 ± 0.026 R@1 (aggregate), 0.621 ± 0.022 R@1 (recency_biased), 0.413 ± 0.026 false-forget (aggregate).

LFUPolicy

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.982 vs Naive's 0.708) — 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.793 ± 0.013 R@1 (aggregate), 0.800 R@1 (high_freq_skew — R@5 0.982), 0.207 ± 0.013 false-forget (aggregate).

TTLPolicy

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 365-day simulation; every fact older than a week gets dropped, and TTL ends up with the worst false-forget rate (0.753 aggregate). On the bursty workload TTL's index size jumps from 30 to 78-126 because burst days dump more items than 7 days can absorb.

Evidence: 0.247 ± 0.050 R@1 (aggregate), 0.160 ± 0.039 R@1 (durable_heavy), 0.753 ± 0.050 false-forget (aggregate).

LIFOPolicy

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 R@1 (aggregate — looks great, by accident of the benchmark's capture order); 0.200 false-forget matches Naive across every workload. 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.

Naive

What it does: no retention policy. Stores every interaction forever.

Not a real choice for production — included as the unbounded baseline. The benchmark shows the cost of "keep everything": a 1677-item index at 365 days (vs Lethe's 1068) and 25ms mean retrieval latency (vs Lethe's 17ms). The 36% index difference comes from Lethe's consolidation pipeline demoting low-signal items.

PinnedOverlay

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.

Choosing between policies in practice

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
Bursty / spiky workload (real-world agents) Lethe FIFO, LRU
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.

License

MIT: see LICENSE.

About

Selective memory for AI agents importance-scored, self-decaying memory that forgets what doesn't matter so retrieval stays fast and relevant over long-running sessions.

Topics

Resources

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages