A high-performance, asynchronous non-blocking hierarchical memory framework for LLM Agents.
Standard LLM memory systems (like LangChain's ConversationSummaryMemory) process conversation history sequentially on the main application thread. Every time a user sends a message, the entire application freezes while the system waits for an LLM to generate a new historical summary. Furthermore, these summaries suffer from the "Lost in the Middle" hallucination effect, frequently deleting specific UUIDs, names, or rules to save tokens.
Sawtooth Memory eliminates this latency and data loss. It immediately stores the user's message and returns control in milliseconds. By default, Dual-Target Externalization (DTE) compacts large tool observations locally and folds old history into an exact entity ledger plus recoverable semantic archive without an LLM call. Optional background narration runs only when query intent, accumulated debt, and a spend-ratio budget justify it.
Standard Memory (Blocking) Sawtooth Memory (Async)
────────────────────────── ───────────────────────
[ Application ] [ Application ]
│ │
▼ ▼
[ Save Context ] [ ContextManager ]
│ │
▼ ├───────────────────┐ (Instant Return)
[ LLM Summarizes ] ▼ ▼
(App freezes for 5-10s) [ Next User Turn ] [ Background Worker ]
│ │
▼ ▼
[ Next User Turn ] [ LLM Summarizes ]
When your agent is ready to respond, Sawtooth stitches together an optimized context payload from distinct layers, ensuring critical facts are never summarized away.
Agent Loop
│
▼
┌─────────────────────┐
│ ContextManager │
│ ┌───────────────┐ │
│ │ L0 System │ │ immutable persona + tool schemas
│ │ L2 Archive │ │ compressed narrative memory
│ │ L1.5 Entities │ │ exact IDs, rolling conflict history
│ │ L1 Working │ │ recent raw conversation
│ └───────────────┘ │
└──────────┬──────────┘
│
▼
build_prompt() / get_compiled_prompt()
│
▼
LLM API
- Phase 2 Update to L1.5: The Entity Ledger now utilizes a rolling window history. Instead of overwriting older values, it preserves conflicts and automatically injects a
<key>__historyvariable into the prompt so the LLM can see the chronological provenance of changing variables.
- Zero-Latency Ingestion: Messages are appended to L1 instantly. A local
tiktokenmonitor checks thresholds without making API calls. - Dual LLM Compression Backends: Run compression locally via
OllamaCompressoror in the cloud usingCloudCompressor(with modular adapters for OpenAI, Anthropic, and Gemini). - Dual-Target Externalization: Zero-LLM soft-limit folding, reversible local
tool-output compaction, intent-scoped retrieval, novelty filtering, and a hard
ratio cap on background compressor tokens. Use
compression_mode="always_llm"for legacy eager summarization. - Deterministic NER Engine: A zero-latency local regex pipeline extracts UUIDs, file paths, and URIs before the LLM sees the text, securely populating the Entity Ledger (L1.5) and overriding potential LLM hallucinations.
- Salience Entity Guard: A local heuristic extractor catches unstructured identifiers (ticket IDs, tracking codes, reference numbers) that regex misses. Protection manifests, post-merge verification, and ingest-time scanning keep critical values out of compression loss.
- Turn-Based Batching & Debouncing: Prevent background queue flooding using
max_unsummarized_turnsto trigger compression safely by turn count, alongside token limits. - Graceful Degradation: If the system hits the
hard_limit_tokensbefore the asynchronous background worker finishes a cycle, a fallback protocol forcefully truncates the oldest L1 messages on the main thread to prevent API crashes.
By moving compression to the background, Sawtooth eliminates per-turn main-thread blocking while maintaining 100% recall accuracy.
Live GPU Benchmark (NVIDIA RTX 5060 | Ollama phi4-mini | 10-Turn / 20-Message Conversation)
| Performance Metric | Standard Summary Memory | Sawtooth Hierarchical | Architectural Advantage |
|---|---|---|---|
| User-perceived turn latency (p95) | 24.3 seconds | <0.1 ms | Main thread unblocked during conversation |
| Mean blocked per turn | 7.8 seconds | 0.03 ms | Compression off the critical path |
| Total main-thread blocked (10 turns) | 78.2 seconds | <2 ms | No per-turn GPU wait |
| Session-end background drain | — | 1.1 seconds | One-time flush at cm.stop() |
| Final Prompt Payload | 563 tokens | 866 tokens | Structured L1.5 ledger preserves exact facts |
| UUID / Fact Recall | 0% (lost after summarization) | 100% Retained | Guaranteed via L1.5 Ledger |
For full methodology, reproducibility steps, and the complete benchmark suite, view our Performance Benchmarks.
pip install sawtooth-memory
Optional dependencies:
# Cloud compression uses httpx directly — set API keys via environment variables
# (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY)
# LangChain message history adapter
pip install sawtooth-memory[langchain]
# LangGraph integration
pip install sawtooth-memory[langgraph]
# Distributed session storage
pip install sawtooth-memory[redis]
# Durable Postgres + pgvector storage
pip install sawtooth-memory[postgres]
# Everything
pip install sawtooth-memory[all]Use SyncContextManager for linear scripts with no asyncio boilerplate. Compression runs inline when token limits are hit (the calling thread blocks until compression finishes).
from sawtooth_memory import SyncContextManager, ContextManagerConfig
config = ContextManagerConfig.for_sync_script(soft_limit_tokens=1500)
with SyncContextManager("You are a helpful assistant.", config=config) as memory:
memory.add_message("user", "Ticket INC-4421 needs escalation.")
memory.add_message("assistant", "I'll look up INC-4421 now.")
prompt = memory.build_prompt()
print(prompt)See examples/simple_sync_script.py for a full runnable example. Additional deep-dives live under examples/ (sync portal, cloud backend, multi-agent pool, Postgres+L3).
| API | When to use | Compression behavior |
|---|---|---|
SyncContextManager |
Scripts, WSGI, notebooks | Blocking inline on soft/hard limits |
ContextManager |
FastAPI, LangGraph, asyncio agents | Non-blocking background worker |
SawtoothSyncWrapper |
Sync app that needs async worker behavior | Non-blocking via AnyIO daemon thread |
The V2 configuration introduces dynamic validation, allowing you to set a single background_model parameter that automatically routes to the respective local or cloud backend. Cloud models (gpt-*, claude-*, gemini-*) read API keys from standard environment variables (OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY).
import asyncio
from sawtooth_memory import ContextManager, ContextManagerConfig
async def main():
# V2 Simplified Configuration
config = ContextManagerConfig(
background_model="gpt-4o-mini", # Auto-routes to CloudCompressor (or "phi4" for local Ollama)
soft_limit_tokens=1000, # Token threshold to trigger background compression
hard_limit_tokens=2000, # Emergency truncation limit
max_unsummarized_turns=10, # Turn-based batching threshold
enable_deterministic_ner=True, # Enable local regex + salience extraction for the Entity Ledger
enable_salience_extractor=True, # Catch unstructured IDs (e.g. INC-4421, ALPHA-991)
enable_ingest_entity_scan=True, # Scan incoming messages at add_message() time
)
async with ContextManager(system_prompt="You are a helpful assistant.", config=config) as cm:
# Optional: Run a health check to verify backend routing and worker status
await cm.health_check()
# 1. Instantly ingest messages (Zero-latency on the main thread)
await cm.add_message("user", "My transaction ID is txn_998877_alpha")
await cm.add_message("assistant", "I have noted your transaction ID.")
# 2. Build the optimized prompt to send to your main LLM
prompt = await cm.build_prompt()
print(prompt)
if __name__ == "__main__":
asyncio.run(main())For explicit cloud configuration without environment variables:
from sawtooth_memory import CloudConfig, ContextManagerConfig, Provider
config = ContextManagerConfig(
cloud=CloudConfig(
provider=Provider.OPENAI,
model="gpt-4o-mini",
api_key="sk-...",
),
)Sawtooth protects critical identifiers through a layered, local-first Entity Guard pipeline. Regex handles known formats; a salience heuristic catches unstructured values regex cannot see.
Layer 1 — Regex (high precision): UUIDs, file paths, URIs, and custom patterns.
Layer 2 — Salience heuristics: Cue-word proximity, structural shape, entropy, and rarity scoring promote identifiers like INC-4421 or ALPHA-991 into L1.5 without predefined regex.
Layer 3 — Protection manifest: Locally discovered entities are injected into the compression prompt so the LLM must preserve them verbatim.
Layer 4 — Post-merge verifier: If the compression LLM still drops a protected value, it is force-reinjected into the ledger.
config = ContextManagerConfig(
enable_deterministic_ner=True,
custom_ner_patterns={
"aws_arn": r"arn:aws:[a-z0-9\-]+:[a-z0-9\-]+:\d{12}:[a-zA-Z0-9\-\_/]+"
},
enable_salience_extractor=True,
salience_threshold=0.5,
enable_ingest_entity_scan=True,
enable_entity_verifier=True,
)
async with ContextManager("You are an agent.", config=config) as cm:
# Ingest-time scan catches unstructured IDs immediately
await cm.add_message("user", "Escalate ticket INC-4421 to on-call.")
# Or pin a value explicitly
await cm.pin_entity("tracking_code", "ALPHA-991")Extraction strategies are tracked in telemetry and explainability traces: deterministic, salience_heuristic, pinned, and llm_synthesis.
Sawtooth provides a native SawtoothLangGraphAdapter to sync state seamlessly.
V2 Safety Feature: Strict cloud APIs (like Anthropic/OpenAI) will crash if a ToolMessage is sent without its parent AIMessage (the tool call request). The LangGraph adapter includes an advanced 3-pass sanitization logic that automatically detects and drops orphaned ToolMessages when their parent AIMessage has been compressed and evicted to L2 Archival Memory.
from langgraph.graph import StateGraph
from sawtooth_memory.integrations.langgraph import SawtoothLangGraphAdapter
# Initialize the adapter with your Sawtooth ContextManager
adapter = SawtoothLangGraphAdapter(cm)
# Automatically syncs state, deduplicates message IDs, and sanitizes orphaned tools
sanitized_messages = await adapter.sync_and_sanitize(langgraph_state_messages)Sawtooth provides a native, pure-Python adapter that fully implements LangChain's modern BaseChatMessageHistory interface. This allows you to drop Sawtooth directly into any LCEL Runnable or Agent executor, bringing background compression and deterministic NER to standard LangChain pipelines without blocking the main thread.
from langchain_core.messages import HumanMessage
from sawtooth_memory.integrations.langchain_adapter import SawtoothChatMessageHistory
# Drop-in replacement for any LangChain memory module
history = SawtoothChatMessageHistory(
system_prompt="You are a financial analyst.",
config=config
)
history.add_message(HumanMessage(content="Analyze these Q3 numbers."))
# Automatically compiles the L0, L1.5, L2, and L1 tiers safely across thread boundaries
lc_messages = history.messagesSawtooth formally supports three host patterns. Prefer the simplest that meets your latency budget:
| API | Host | Compression |
|---|---|---|
SyncContextManager |
Scripts, Flask, Django, notebooks | Blocking inline |
SawtoothSyncWrapper |
Sync hosts that need async-worker semantics | Non-blocking (AnyIO portal) |
ContextManager |
FastAPI, LangGraph, asyncio | Non-blocking worker |
Full method parity on sync APIs: add_message, pin_entity, retrieve_observation, build_prompt, explain_prompt, L3 helpers, .state, get_stats, health_check.
See DOCUMENTATION.md §5 and §10 API Reference.
from sawtooth_memory import SyncContextManager, ContextManagerConfig
def my_flask_route():
config = ContextManagerConfig.for_sync_script(soft_limit_tokens=1500)
with SyncContextManager("You are a helpful assistant.", config=config) as memory:
memory.add_message("user", "Hello world!")
memory.pin_entity("session_note", "hello")
prompt = memory.build_prompt()
return promptUse when ingest must stay fast while compression continues in the background:
from sawtooth_memory import SawtoothSyncWrapper, ContextManagerConfig
config = ContextManagerConfig(soft_limit_tokens=1500)
def my_flask_route():
with SawtoothSyncWrapper("You are a helpful assistant.", config=config) as memory:
memory.add_message("user", "Hello world!")
prompt = memory.build_prompt()
return promptSawtooth eliminates the "black-box" of agent memory by providing deterministic audit trails. You can query the memory system to see exactly why a fact was retained in the prompt.
trace = cm.explain_prompt()
import json
print(json.dumps(trace, indent=2))Output:
{
"l0_system": {
"content": "You are a helpful assistant.",
"origin": "Hardcoded System Initialization"
},
"l2_archival": {
"content": "User provided transaction ID txn_998877_alpha.",
"origin": "Background Ollama Compression (L1 -> L2)"
},
"l1_5_entities": [
{
"prompt_component": "[ENTITY_LEDGER_L1_5]",
"entity_key": "user_transaction_id",
"entity_value": "txn_998877_alpha",
"origin": "Anchored via explicit tracking engine (Operation: insert) [Strategy: salience_heuristic]",
"confidence": "90% (Salience Heuristic)"
}
],
"l1_working_messages": 2
}By default, Sawtooth manages process state locally. For multi-container stateless applications (e.g., load-balanced FastAPI apps or Kubernetes pods), Sawtooth provides an abstract storage layer to decouple memory data from active server process memory RAM.
The RedisStorageAdapter serializes your state trees to high-speed JSON structures natively, allowing multiple distinct node pods to process background worker loops seamlessly without cross-session data overwrites.
import asyncio
from sawtooth_memory import ContextManager, ContextManagerConfig, RedisStorageAdapter
async def main():
# Initialize the high-speed distributed storage backend
redis_storage = RedisStorageAdapter(
redis_url="redis://localhost:6379/0",
key_prefix="sawtooth:session:",
ttl_seconds=86400 # Automatically expire inactive sessions after 24 hours
)
config = ContextManagerConfig(
background_model="gpt-4o-mini",
storage_adapter=redis_storage,
session_id="user_session_994" # Route state changes dynamically via custom keys
)
async with ContextManager(system_prompt="You are a cluster node agent.", config=config) as cm:
await cm.add_message("user", "Save this cluster token: secret_pass_123")
# Hydrates state directly across node instances instantly!
prompt = await cm.build_prompt()Sawtooth can index evicted L1 conversation text into a pgvector-backed L3 semantic archive during background compression. Vectors are stored separately from the JSONB MemoryState payload to keep session snapshots lean.
Important: L3 retrieval is automatically injected into build_prompt() when enabled. You can also manually query it via search_semantic_archive().
Requirements:
PostgresStorageAdapterwith the PostgreSQLvectorextension installedenable_l3_semantic_storage=TrueonContextManagerConfig- Matching
embedding_dimensionon both the adapter and config
import asyncio
from sawtooth_memory import ContextManager, ContextManagerConfig, PostgresStorageAdapter
async def main():
postgres = PostgresStorageAdapter(
dsn="postgresql://user:pass@localhost:5432/sawtooth",
embedding_dimension=384,
)
config = ContextManagerConfig(
background_model="gpt-4o-mini",
storage_adapter=postgres,
session_id="user_session_994",
enable_l3_semantic_storage=True,
enable_l3_prompt_retrieval=True, # Automatically retrieves chunks
embedding_backend="hash", # "openai" for production embeddings
embedding_dimension=384,
l3_chunk_max_chars=2000,
)
async with ContextManager(system_prompt="You are a cluster node agent.", config=config) as cm:
await cm.add_message("user", "Router firmware is v2.4.1 and drops packets nightly.")
# After background compression, evicted L1 text is chunked, embedded, and stored in L3.
# The next build_prompt() will automatically retrieve relevant L3 chunks
prompt = await cm.build_prompt()
# Storage-layer retrieval (manual):
matches = await cm.search_semantic_archive("router firmware packets", top_k=3)
for chunk in matches:
print(f"[{chunk.similarity:.2f}] {chunk.text}")
if __name__ == "__main__":
asyncio.run(main())-
Phase 1: Core Architecture
-
L1/L2 Hierarchical Buffer
-
Asynchronous Background Worker
-
Local (Ollama) & Cloud compatibility
-
Phase 2: Observability & Stability
-
EventBus Subsystem & JSONL Auditing Journal
-
Explainability Traces & Performance Benchmarking Harness
-
Deterministic NER Engine
-
Salience Entity Guard (unstructured ID protection)
-
LangGraph ToolMessage Sanitization
-
Max Unsummarized Turns (Turn-based Batching)
-
Modern LangChain
BaseChatMessageHistoryAdapter -
Synchronous AnyIO Blocking Portal Wrapper
-
Phase 3: Advanced Architectures
-
Redis Distributed Storage Adapter (High-Speed Session Pooling)
-
Postgres Storage Adapter (Persistent Relational Cache with pgvector)
-
Multi-Agent Memory Pooling (Shared contextual state)
-
L3 Semantic Vector Storage Layer
-
Phase 4: RAG Integration
-
Semantic Vector L3 Archival Memory (Retrieval injected into
build_prompt()) -
Phase 5: Public API, Sync DX & Docs
-
Dual-Target Externalization (DTE) default compression path
-
Sync-native
SyncContextManager+ formal sync host guidance -
Expanded package-root public exports (storage, events, embeddings, sync wrapper)
-
Detailed API Reference + deeper examples (pool, Postgres/L3, cloud, sync portal)
-
Release polish: CHANGELOG/tag alignment,
STABILITY.md, PyPI release workflow
We welcome pull requests. See our CONTRIBUTING.md for guidelines on how to run the test suite and ensure code quality. Stable API guarantees are documented in STABILITY.md.
This project is licensed under the MIT License - see the LICENSE.md file for details.