Coverage for agentos/memory/pager.py: 28%
194 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 09:19 +0800
1"""
2Virtual Memory Pager — Letta-style context memory swapping.
4Inspired by OS virtual memory: when the agent's working context fills up,
5pages out old/low-importance episodic memories to a persistent swap store,
6and intelligently pages them back in when relevant to the current task.
8Architecture:
9 L1 (in-memory) ──page_out──→ SwapStore (disk/DB)
10 SwapStore ──page_in───→ L1 (promoted back)
12Key features:
13- Importance-weighted eviction: least important + oldest first
14- Smart recall: semantic search in swap for context-relevant pages
15- Page compaction: batch-compress multiple items into summary pages
16- Statistics: track page hits/misses for tuning
17"""
19from __future__ import annotations
21import json
22import os
23import time
24import uuid
25from collections.abc import Callable
26from dataclasses import dataclass, field
27from typing import Any
29from agentos.memory.pyramid import MemoryItem, MemoryLayer, MemoryPyramid, MemoryType
31# ── Data Structures ──────────────────────────────────────────────
34@dataclass
35class MemoryPage:
36 """A compressed page of evicted memories, like a virtual memory page."""
38 id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
39 items: list[dict[str, Any]] = field(default_factory=list) # serialized MemoryItem dicts
40 summary: str = "" # LLM-generated summary of page contents
41 keywords: list[str] = field(default_factory=list)
42 importance_avg: float = 0.0
43 item_count: int = 0
44 evicted_at: float = field(default_factory=time.time)
45 evicted_from: str = "l1" # which layer it was evicted from
47 def to_dict(self) -> dict[str, Any]:
48 return {
49 "id": self.id,
50 "items": self.items,
51 "summary": self.summary,
52 "keywords": self.keywords,
53 "importance_avg": self.importance_avg,
54 "item_count": self.item_count,
55 "evicted_at": self.evicted_at,
56 "evicted_from": self.evicted_from,
57 }
59 @classmethod
60 def from_dict(cls, d: dict[str, Any]) -> MemoryPage:
61 return cls(
62 id=d.get("id", uuid.uuid4().hex[:12]),
63 items=d.get("items", []),
64 summary=d.get("summary", ""),
65 keywords=d.get("keywords", []),
66 importance_avg=d.get("importance_avg", 0.0),
67 item_count=d.get("item_count", 0),
68 evicted_at=d.get("evicted_at", time.time()),
69 evicted_from=d.get("evicted_from", "l1"),
70 )
73@dataclass
74class PagerStats:
75 """Pager performance statistics."""
77 total_page_outs: int = 0
78 total_page_ins: int = 0
79 total_items_evicted: int = 0
80 total_items_recalled: int = 0
81 page_hits: int = 0 # page-in found relevant data
82 page_misses: int = 0 # page-in found nothing relevant
83 last_page_out_at: float = 0.0
84 last_page_in_at: float = 0.0
86 @property
87 def hit_rate(self) -> float:
88 total = self.page_hits + self.page_misses
89 return self.page_hits / total if total > 0 else 0.0
92# ── Swap Store Backend ────────────────────────────────────────────
95class SwapStore:
96 """
97 Persistent storage for paged-out memories.
99 Default: file-based JSON store. Can be swapped for SQLite/Postgres.
100 """
102 def __init__(self, path: str = ""):
103 self.path = path or self._default_path()
104 self._pages: dict[str, MemoryPage] = {}
105 self._keyword_index: dict[str, set[str]] = {} # keyword → page_ids
106 os.makedirs(os.path.dirname(self.path), exist_ok=True)
107 self._load()
109 @staticmethod
110 def _default_path() -> str:
111 return os.path.join(os.path.expanduser("~"), ".agentos", "memory_swap.json")
113 def store(self, page: MemoryPage) -> None:
114 self._pages[page.id] = page
115 for kw in page.keywords:
116 self._keyword_index.setdefault(kw.lower(), set()).add(page.id)
117 self._flush()
119 def search(self, query_keywords: list[str], limit: int = 5) -> list[MemoryPage]:
120 """Keyword-based search for relevant pages."""
121 scored: dict[str, float] = {}
122 for kw in query_keywords:
123 kw_lower = kw.lower()
124 for page_id in self._keyword_index.get(kw_lower, set()):
125 scored[page_id] = scored.get(page_id, 0) + 1.0
127 # Sort by score desc, then by importance_avg desc
128 ranked = sorted(
129 scored.items(),
130 key=lambda x: (x[1], self._pages.get(x[0], MemoryPage()).importance_avg),
131 reverse=True,
132 )
133 return [self._pages[pid] for pid, _ in ranked[:limit]]
135 def get(self, page_id: str) -> MemoryPage | None:
136 return self._pages.get(page_id)
138 def remove(self, page_id: str) -> bool:
139 page = self._pages.pop(page_id, None)
140 if page:
141 for kw in page.keywords:
142 idx = self._keyword_index.get(kw.lower(), set())
143 idx.discard(page_id)
144 if not idx:
145 del self._keyword_index[kw.lower()]
146 self._flush()
147 return True
148 return False
150 def list_all(self) -> list[MemoryPage]:
151 return list(self._pages.values())
153 def clear(self) -> None:
154 self._pages.clear()
155 self._keyword_index.clear()
156 self._flush()
158 def _flush(self) -> None:
159 try:
160 with open(self.path, "w") as f:
161 json.dump(
162 {
163 "pages": {k: v.to_dict() for k, v in self._pages.items()},
164 "keyword_index": {k: list(v) for k, v in self._keyword_index.items()},
165 },
166 f,
167 indent=2,
168 )
169 except Exception:
170 pass
172 def _load(self) -> None:
173 if not os.path.exists(self.path):
174 return
175 try:
176 with open(self.path) as f:
177 data = json.load(f)
178 self._pages = {k: MemoryPage.from_dict(v) for k, v in data.get("pages", {}).items()}
179 self._keyword_index = {k: set(v) for k, v in data.get("keyword_index", {}).items()}
180 except Exception:
181 self._pages = {}
182 self._keyword_index = {}
185# ── Memory Pager ──────────────────────────────────────────────────
188class MemoryPager:
189 """
190 Virtual memory pager for Agent context.
192 Pages out old/less-important memories to swap when context is full,
193 and intelligently pages them back in when needed.
195 Usage:
196 pager = MemoryPager(pyramid, summarizer_fn=my_summarizer)
197 paged = await pager.page_out(ratio=0.8) # evict when 80% full
198 recalled = await pager.page_in(["python", "error", "debug"])
199 """
201 def __init__(
202 self,
203 pyramid: MemoryPyramid,
204 summarizer_fn: Callable | None = None,
205 swap_store: SwapStore | None = None,
206 max_pages: int = 500,
207 page_size: int = 10, # items per page
208 eviction_ratio: float = 0.3, # evict this % of working+episodic when full
209 ):
210 self.pyramid = pyramid
211 self.summarizer = summarizer_fn
212 self.swap = swap_store or SwapStore()
213 self.max_pages = max_pages
214 self.page_size = page_size
215 self.eviction_ratio = eviction_ratio
216 self.stats = PagerStats()
218 async def page_out(self, fill_ratio: float = 0.85) -> int:
219 """
220 Evict low-importance episodic+working memories to swap.
222 Called when context is approaching the token limit.
224 Args:
225 fill_ratio: Current context fill ratio (0-1)
227 Returns:
228 Number of items evicted
229 """
230 if fill_ratio < 0.7:
231 return 0 # Not full enough yet
233 # Determine scale: the fuller it is, the more aggressive
234 scale = min(1.0, (fill_ratio - 0.7) / 0.3)
235 to_evict_count = max(
236 1, int(len(self.pyramid._memories[MemoryType.EPISODIC]) * self.eviction_ratio * scale)
237 )
239 # Collect candidates: episodic + old working memories
240 candidates: list[tuple[str, MemoryItem]] = []
241 for key, item in self.pyramid._memories[MemoryType.EPISODIC].items():
242 candidates.append((key, item))
243 for key, item in self.pyramid._memories[MemoryType.WORKING].items():
244 if item.access_count < 3: # Only evict rarely-accessed working memories
245 candidates.append((key, item))
247 if not candidates:
248 return 0
250 # Sort by (importance asc, age desc) — evict least important, oldest first
251 now = time.time()
252 candidates.sort(key=lambda x: (x[1].importance, -(now - x[1].created_at)))
253 to_evict = candidates[:to_evict_count]
255 # Batch into pages
256 pages_created = 0
257 for i in range(0, len(to_evict), self.page_size):
258 batch = to_evict[i : i + self.page_size]
259 page = await self._create_page(batch)
260 self.swap.store(page)
261 pages_created += 1
263 self.stats.total_page_outs += pages_created
264 self.stats.total_items_evicted += len(to_evict)
265 self.stats.last_page_out_at = time.time()
267 return len(to_evict)
269 async def page_in(self, query_keywords: list[str], limit: int = 3) -> list[MemoryItem]:
270 """
271 Search swap for relevant memories and promote them back to L1.
273 Args:
274 query_keywords: Keywords to search for (e.g., current task description)
275 limit: Max pages to recall
277 Returns:
278 List of MemoryItem that were restored
279 """
280 pages = self.swap.search(query_keywords, limit=limit)
282 if not pages:
283 self.stats.page_misses += 1
284 return []
286 self.stats.page_hits += 1
287 self.stats.last_page_in_at = time.time()
289 restored_items: list[MemoryItem] = []
290 for page in pages:
291 for item_dict in page.items:
292 try:
293 item = MemoryItem.from_dict(item_dict)
294 # Promote back to L1 episodic memory
295 item.layer = MemoryLayer.L1
296 key = item_dict.get("metadata", {}).get("key", item.id)
297 self.pyramid._memories[MemoryType.EPISODIC][key] = item
298 self.pyramid._index[key] = item
299 restored_items.append(item)
300 except Exception:
301 continue
302 # Remove the page from swap (it's back in memory now)
303 self.swap.remove(page.id)
305 self.stats.total_page_ins += len(pages)
306 self.stats.total_items_recalled += len(restored_items)
308 return restored_items
310 async def _create_page(self, items: list[tuple[str, MemoryItem]]) -> MemoryPage:
311 """Create a compressed memory page from a batch of items."""
312 page = MemoryPage()
313 keywords_set: set[str] = set()
314 total_imp = 0.0
316 for key, item in items:
317 item_dict = {
318 **item.to_dict(),
319 "metadata": {**item.metadata, "key": key},
320 }
321 page.items.append(item_dict)
323 # Extract keywords from content and metadata
324 if isinstance(item.content, str):
325 for word in item.content.lower().split()[:20]:
326 if len(word) > 3 and word.isalpha():
327 keywords_set.add(word)
328 for v in item.metadata.values():
329 if isinstance(v, str) and len(v) < 50:
330 keywords_set.add(v.lower())
332 total_imp += item.importance
334 # Remove from pyramid
335 self.pyramid._memories[item.type].pop(key, None)
336 self.pyramid._index.pop(key, None)
338 page.item_count = len(items)
339 page.importance_avg = total_imp / len(items) if items else 0.0
340 page.keywords = list(keywords_set)
342 # Generate summary via LLM if available
343 if self.summarizer and page.items:
344 try:
345 contents = "\n".join(
346 i.get("content", "") for i in page.items if isinstance(i.get("content"), str)
347 )[:2000]
348 page.summary = await self.summarizer(contents)
349 except Exception:
350 page.summary = f"{len(items)} memories, avg importance {page.importance_avg:.2f}"
352 return page
354 def get_stats(self) -> dict[str, Any]:
355 """Get comprehensive pager statistics."""
356 return {
357 "total_page_outs": self.stats.total_page_outs,
358 "total_page_ins": self.stats.total_page_ins,
359 "total_items_evicted": self.stats.total_items_evicted,
360 "total_items_recalled": self.stats.total_items_recalled,
361 "page_hits": self.stats.page_hits,
362 "page_misses": self.stats.page_misses,
363 "hit_rate": f"{self.stats.hit_rate:.1%}",
364 "swap_pages_stored": len(self.swap.list_all()),
365 "last_page_out": self.stats.last_page_out_at,
366 "last_page_in": self.stats.last_page_in_at,
367 }
370# ── Loop Integration Helper ────────────────────────────────────────
373def create_paging_callback(pager: MemoryPager) -> Callable:
374 """
375 Create a callback for the agent loop's auto-paging hook.
377 Usage:
378 pager = MemoryPager(pyramid)
379 loop.set_auto_paging(create_paging_callback(pager))
380 """
382 async def auto_paging(usage_ratio: float) -> int:
383 evicted = await pager.page_out(usage_ratio)
384 return evicted
386 return auto_paging
389async def recall_relevant_memories(
390 pager: MemoryPager,
391 task_description: str,
392 limit: int = 3,
393) -> list[MemoryItem]:
394 """
395 Recall memories relevant to a task from swap.
397 Extracts keywords from task description and pages in relevant memories.
399 Usage:
400 items = await recall_relevant_memories(pager, "debug the database connection error")
401 """
402 keywords = [w.lower() for w in task_description.split() if len(w) > 3 and w.isalpha()]
403 return await pager.page_in(keywords, limit=limit)
406# ── Auto-generated compat stubs ──
409def recall_relevant_memories(*args, **kwargs): # noqa: F811
410 pass