Sawtooth Memory

2026-06-09 · page · source ↗ · source summary

An async, non-blocking hierarchical memory framework for LLM agents that solves two problems at once: main-thread latency from synchronous summarization, and the "Lost in the Middle" hallucination problem where summaries silently drop UUIDs, connection strings, and other deterministic values. The core insight: decouple message ingestion from compression by running summarization on a background asyncio worker, and extract critical exact values into a separate immutable ledger (L1.5) before compression, guaranteeing 100% fact recall regardless of summary quality. Drop-in middleware between your agent loop and LLM API call — add_message() returns in microseconds, build_prompt() assembles a four-tier optimized context. Supports local Ollama (default) or cloud backends (OpenAI, Anthropic, Gemini). ~6,800 lines of Python, MIT license, on PyPI.

Architecture

The memory model has four tiers, assembled in priority order on build_prompt():

The non-blocking execution model:

Main Thread                          Background Worker
───────────                          ─────────────────
add_message() → append L1, return    (instant, ~microseconds)
                                     TokenMonitor checks soft/hard limits
                                     Slice oldest chunk_size messages
                                     Enqueue CompressionTask
                                     → LLM compress (Ollama/Cloud)
                                     → Merge narrative → L2
                                     → Upsert entities → L1.5
                                     → Emit cycle_complete event
                                     → Journal write (JSONL)
build_prompt() → stitch L0+L2+L1.5+L1

Key source files: middleware.py:229-268 (add_message flow), worker.py:143-251 (compression loop + state merge), state.py:65-186 (EntityLedger with conflict preservation), compressor.py:84-162 (OllamaCompressor with noise pruning).

Key Techniques

Dual-extraction compression prompt (compressor.py:27-52): The system prompt asks the compression LLM to simultaneously produce a chronological narrative AND extract exact deterministic values into a flat KV dict — two tasks in one LLM call. Structured output enforced differently per provider: Anthropic tool-use (store_compression_result tool with tool_choice: {type: "tool"}), OpenAI response_format: json_object, Gemini responseMimeType: application/json.

Pre-processing noise removal (compressor.py:58-76): Three compiled regexes strip base64 blobs >80 chars, Python/JS stack traces, and long hex runs from messages before they hit the compression LLM — saving tokens and improving summary quality with zero latency cost.

Entity conflict preservation (state.py:122-154): EntityLedger.upsert() appends new values for existing keys to a history list rather than overwriting. Duplicate consecutive values are skipped to avoid duplication across overlapping compression waves. to_json_str() renders the latest value as primary + __history companion key when multiple values exist.

Orphaned ToolMessage sanitization (integrations/langgraph/adapter.py:188-261): When an AIMessage with tool_calls is evicted from L1 into L2, its child ToolMessages become orphaned (their tool_call_id references a parent no longer in the prompt). Sending these to strict APIs causes HTTP 400. The adapter's get_compiled_prompt() runs a three-pass sanitization to detect and drop orphaned ToolMessages.

Debounce lock (monitor.py:72-77): Prevents queue flooding — once compression is triggered, _is_compression_queued blocks further triggers until the worker emits compression.cycle_complete or compression.cycle_failed, resetting the lock via event subscription.

Design Decisions

Optimized for main-thread latency, sacrificed compression quality: Default compression model is phi4-mini:latest (small local model). Summaries will be lower quality than frontier models produce, but the entity ledger (L1.5) preserves exact values regardless. Benchmark claims 11.3x faster vs synchronous summary memory for local models.

Local-first, cloud-optional: Default backend is Ollama (localhost:11434). Cloud is an opt-in config. Makes the library usable in air-gapped environments but means compression quality depends on a capable local model being available.

No locks despite concurrency: DOCUMENTATION claims asyncio.Lock() protection, but the actual code uses no synchronization. MemoryState is a plain Pydantic model mutated by both the main thread and the background worker. Works because of asyncio's cooperative multitasking (task switches only at await), but is a real gap — build_prompt() reading state mid-way through a _merge() could see inconsistent data.

JSONL journal over database: Compression cycles are persisted to append-only JSONL (one JSON object per line) rather than SQLite. Human-readable and trivially parseable, but limits queryability and concurrent access. Phase 3 roadmap promises Redis/Postgres adapters.

Provider adapter Protocol over ABC: ProviderAdapter is a typing.Protocol with @runtime_checkable — structural subtyping, not inheritance. Each adapter is a pure data-construction object (no I/O). Makes the compression backend trivially swappable.

Comparison Notes

Tags

#tool #project #agents #memory #context #llm #middleware #python

Related


Source: https://github.com/HtooTayZa/sawtooth-memory | Fetched 2026-06-09 | Deep analysis from full repo clone