Skip to content

Fqih/Lethe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 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 30-day simulated agent session

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.

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: 30 simulated days, all six retention policies

Averaged over 3 random seeds, default workload (full numbers here):

Aggregate table — all policies side by side

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

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

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

Visual comparison — recall @ 1 by policy

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      ███▊

Visual comparison — false-forget rate by policy

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          ████

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

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 100

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

Which policy should I use?

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.

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

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 (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).

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

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

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

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

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

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages