Coverage for agentos/memory/persistence.py: 19%

190 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 10:59 +0800

1""" 

2AgentOS v1.14.9 — Memory Persistence Manager. 

3 

4Unified save/load for all 12 memory subsystems, bridging the gap between 

5the existing in-memory pyramid and crash-safe disk persistence. 

6 

7All writes are atomic (write to temp file, then rename). JSON format with 

8gzip compression for production efficiency; plain JSON for debug. 

9 

10Usage: 

11 mgr = MemoryPersistenceManager(base_dir="~/.agentos/memory") 

12 

13 # Save everything 

14 await mgr.save_all( 

15 pyramid=pyramid, 

16 working=working, 

17 conversation=conv, 

18 long_term=lterm, 

19 reflection_engine=reflection, 

20 consolidation_pipeline=pipeline, 

21 retriever_index=retriever_index, 

22 ) 

23 

24 # Restore everything 

25 state = await mgr.load_all() 

26""" 

27 

28from __future__ import annotations 

29 

30import gzip 

31import json 

32import os 

33import tempfile 

34import time 

35from dataclasses import dataclass, field 

36from pathlib import Path 

37from typing import Any 

38 

39# ── Snapshot Data Models ────────────────────── 

40 

41 

42@dataclass 

43class MemorySnapshot: 

44 """Complete state of all memory subsystems at a point in time.""" 

45 

46 version: str = "1.14.9" 

47 created_at: float = field(default_factory=time.time) 

48 # Per-subsystem state dicts (optional — only non-empty ones are saved) 

49 pyramid_state: dict[str, Any] = field(default_factory=dict) 

50 working_state: dict[str, Any] = field(default_factory=dict) 

51 conversation_state: dict[str, Any] = field(default_factory=dict) 

52 long_term_state: dict[str, Any] = field(default_factory=dict) 

53 reflection_state: dict[str, Any] = field(default_factory=dict) 

54 consolidation_state: dict[str, Any] = field(default_factory=dict) 

55 retriever_index_state: dict[str, Any] = field(default_factory=dict) 

56 

57 def to_dict(self) -> dict[str, Any]: 

58 result: dict[str, Any] = { 

59 "version": self.version, 

60 "created_at": self.created_at, 

61 } 

62 for field_name in [ 

63 "pyramid_state", 

64 "working_state", 

65 "conversation_state", 

66 "long_term_state", 

67 "reflection_state", 

68 "consolidation_state", 

69 "retriever_index_state", 

70 ]: 

71 val = getattr(self, field_name) 

72 if val: 

73 result[field_name] = val 

74 return result 

75 

76 @classmethod 

77 def from_dict(cls, d: dict[str, Any]) -> MemorySnapshot: 

78 return cls( 

79 version=d.get("version", "1.14.9"), 

80 created_at=d.get("created_at", time.time()), 

81 pyramid_state=d.get("pyramid_state", {}), 

82 working_state=d.get("working_state", {}), 

83 conversation_state=d.get("conversation_state", {}), 

84 long_term_state=d.get("long_term_state", {}), 

85 reflection_state=d.get("reflection_state", {}), 

86 consolidation_state=d.get("consolidation_state", {}), 

87 retriever_index_state=d.get("retriever_index_state", {}), 

88 ) 

89 

90 

91# ── Persistence Manager ────────────────────── 

92 

93 

94class MemoryPersistenceManager: 

95 """Centralized save/load manager for all memory subsystems. 

96 

97 Writes snapshots as compressed JSON files under base_dir. 

98 Supports atomic writes (temp file + rename) and optional gzip compression. 

99 """ 

100 

101 def __init__( 

102 self, 

103 base_dir: str = "", 

104 compress: bool = True, 

105 ): 

106 base = Path(base_dir) if base_dir else Path.home() / ".agentos" / "memory" 

107 base.mkdir(parents=True, exist_ok=True) 

108 self.base_dir: Path = base 

109 self.compress = compress 

110 self._snapshot_path: Path = base / ("snapshot.json.gz" if compress else "snapshot.json") 

111 self._max_backups: int = 3 

112 

113 # ── Save ──────────────────────────────── 

114 

115 async def save_all( 

116 self, 

117 pyramid: Any = None, 

118 working: Any = None, 

119 conversation: Any = None, 

120 long_term: Any = None, 

121 reflection_engine: Any = None, 

122 consolidation_pipeline: Any = None, 

123 retriever_index: dict[str, Any] | None = None, 

124 ) -> str: 

125 """Save all memory subsystems to a single snapshot file. 

126 

127 Each subsystem provides a get_state() / dump_state() method; 

128 we probe for supported interfaces and extract what we can. 

129 

130 Returns the snapshot file path. 

131 """ 

132 snapshot = MemorySnapshot() 

133 

134 if pyramid is not None: 

135 try: 

136 snapshot.pyramid_state = pyramid.get_state() 

137 except AttributeError: 

138 pass 

139 

140 if working is not None: 

141 try: 

142 snapshot.working_state = working.get_state() 

143 except AttributeError: 

144 pass 

145 

146 if conversation is not None: 

147 try: 

148 snapshot.conversation_state = conversation.get_state() 

149 except AttributeError: 

150 pass 

151 

152 if long_term is not None: 

153 try: 

154 snapshot.long_term_state = long_term.get_state() 

155 except AttributeError: 

156 pass 

157 

158 if reflection_engine is not None: 

159 try: 

160 snapshot.reflection_state = reflection_engine.get_state() 

161 except AttributeError: 

162 pass 

163 

164 if consolidation_pipeline is not None: 

165 try: 

166 snapshot.consolidation_state = consolidation_pipeline.get_state() 

167 except AttributeError: 

168 pass 

169 

170 if retriever_index is not None: 

171 snapshot.retriever_index_state = retriever_index 

172 

173 return self._atomic_write(snapshot) 

174 

175 def save_sync( 

176 self, 

177 pyramid: Any = None, 

178 working: Any = None, 

179 conversation: Any = None, 

180 long_term: Any = None, 

181 reflection_engine: Any = None, 

182 consolidation_pipeline: Any = None, 

183 retriever_index: dict[str, Any] | None = None, 

184 ) -> str: 

185 """Synchronous save — for use in signal handlers / atexit hooks.""" 

186 snapshot = MemorySnapshot() 

187 

188 for obj, attr in [ 

189 (pyramid, "pyramid_state"), 

190 (working, "working_state"), 

191 (conversation, "conversation_state"), 

192 (long_term, "long_term_state"), 

193 (reflection_engine, "reflection_state"), 

194 (consolidation_pipeline, "consolidation_state"), 

195 ]: 

196 if obj is not None: 

197 try: 

198 setattr(snapshot, attr, obj.get_state()) 

199 except AttributeError: 

200 pass 

201 

202 if retriever_index is not None: 

203 snapshot.retriever_index_state = retriever_index 

204 

205 return self._atomic_write(snapshot) 

206 

207 # ── Load ──────────────────────────────── 

208 

209 async def load_all(self) -> MemorySnapshot: 

210 """Load the latest memory snapshot from disk. 

211 

212 Returns a MemorySnapshot; empty fields mean no saved state for that subsystem. 

213 """ 

214 if not self._snapshot_path.exists(): 

215 return MemorySnapshot() 

216 

217 data = self._read_snapshot_file() 

218 if data is None: 

219 return MemorySnapshot() 

220 

221 return MemorySnapshot.from_dict(data) 

222 

223 def load_sync(self) -> MemorySnapshot: 

224 """Synchronous load.""" 

225 if not self._snapshot_path.exists(): 

226 return MemorySnapshot() 

227 

228 data = self._read_snapshot_file() 

229 if data is None: 

230 return MemorySnapshot() 

231 

232 return MemorySnapshot.from_dict(data) 

233 

234 async def restore_all( 

235 self, 

236 pyramid: Any = None, 

237 working: Any = None, 

238 conversation: Any = None, 

239 long_term: Any = None, 

240 reflection_engine: Any = None, 

241 consolidation_pipeline: Any = None, 

242 retriever_index_target: dict[str, Any] | None = None, 

243 ) -> int: 

244 """Load snapshot from disk and restore into live objects. 

245 

246 Each target object must have a restore_state(state_dict) method. 

247 Returns count of subsystems restored. 

248 """ 

249 snapshot = await self.load_all() 

250 restored = 0 

251 

252 for obj, state_attr in [ 

253 (pyramid, "pyramid_state"), 

254 (working, "working_state"), 

255 (conversation, "conversation_state"), 

256 (long_term, "long_term_state"), 

257 (reflection_engine, "reflection_state"), 

258 (consolidation_pipeline, "consolidation_state"), 

259 ]: 

260 state = getattr(snapshot, state_attr, {}) 

261 if obj is not None and state: 

262 try: 

263 obj.restore_state(state) 

264 restored += 1 

265 except AttributeError: 

266 pass 

267 

268 if retriever_index_target is not None and snapshot.retriever_index_state: 

269 retriever_index_target.clear() 

270 retriever_index_target.update(snapshot.retriever_index_state) 

271 restored += 1 

272 

273 return restored 

274 

275 # ── Atomic write ──────────────────────── 

276 

277 def _atomic_write(self, snapshot: MemorySnapshot) -> str: 

278 """Write snapshot atomically: temp file → rename.""" 

279 data = snapshot.to_dict() 

280 json_bytes = json.dumps(data, ensure_ascii=False, indent=2, default=str).encode("utf-8") 

281 

282 if self.compress: 

283 json_bytes = gzip.compress(json_bytes, compresslevel=6) 

284 

285 # Write to temp file, then rename 

286 fd, tmp_path = tempfile.mkstemp( 

287 dir=str(self.base_dir), 

288 prefix=".snapshot-tmp-", 

289 suffix=".json.gz" if self.compress else ".json", 

290 ) 

291 try: 

292 with os.fdopen(fd, "wb") as f: 

293 f.write(json_bytes) 

294 

295 # Rotate old backups 

296 self._rotate_backups() 

297 

298 os.replace(tmp_path, str(self._snapshot_path)) 

299 except Exception: 

300 try: 

301 os.unlink(tmp_path) 

302 except OSError: 

303 pass 

304 raise 

305 

306 return str(self._snapshot_path) 

307 

308 # ── Read snapshot ──────────────────────── 

309 

310 def _read_snapshot_file(self) -> dict[str, Any] | None: 

311 """Read and parse snapshot file. Returns None on failure.""" 

312 try: 

313 with open(self._snapshot_path, "rb") as f: 

314 raw = f.read() 

315 

316 if self.compress: 

317 raw = gzip.decompress(raw) 

318 

319 return json.loads(raw.decode("utf-8")) 

320 except (OSError, json.JSONDecodeError, gzip.BadGzipFile): 

321 return None 

322 

323 # ── Backup rotation ────────────────────── 

324 

325 def _rotate_backups(self) -> None: 

326 """Rotate old snapshot backups, keeping self._max_backups.""" 

327 for i in range(self._max_backups - 1, 0, -1): 

328 old_path = self.base_dir / f"snapshot.{i}.json.gz" 

329 new_path = self.base_dir / f"snapshot.{i + 1}.json.gz" 

330 if old_path.exists(): 

331 try: 

332 os.replace(str(old_path), str(new_path)) 

333 except OSError: 

334 pass 

335 

336 # Rotate current into .1 

337 if self._snapshot_path.exists(): 

338 backup_path = self.base_dir / "snapshot.1.json.gz" 

339 try: 

340 os.replace(str(self._snapshot_path), str(backup_path)) 

341 except OSError: 

342 pass 

343 

344 # ── Query ──────────────────────────────── 

345 

346 def snapshot_info(self) -> dict[str, Any]: 

347 """Return metadata about the current snapshot.""" 

348 if not self._snapshot_path.exists(): 

349 return {"exists": False} 

350 

351 try: 

352 stat = self._snapshot_path.stat() 

353 snapshot = self.load_sync() 

354 

355 subsystems_saved = sum( 

356 1 

357 for v in [ 

358 snapshot.pyramid_state, 

359 snapshot.working_state, 

360 snapshot.conversation_state, 

361 snapshot.long_term_state, 

362 snapshot.reflection_state, 

363 snapshot.consolidation_state, 

364 snapshot.retriever_index_state, 

365 ] 

366 if v 

367 ) 

368 

369 return { 

370 "exists": True, 

371 "path": str(self._snapshot_path), 

372 "size_bytes": stat.st_size, 

373 "created_at": snapshot.created_at, 

374 "version": snapshot.version, 

375 "subsystems_saved": subsystems_saved, 

376 "compressed": self.compress, 

377 } 

378 except Exception: 

379 return {"exists": True, "error": "unreadable"} 

380 

381 def delete_snapshot(self) -> bool: 

382 """Delete the current snapshot file(s).""" 

383 deleted = False 

384 for path in self.base_dir.glob("snapshot*.json.gz"): 

385 try: 

386 path.unlink() 

387 deleted = True 

388 except OSError: 

389 pass 

390 for path in self.base_dir.glob("snapshot*.json"): 

391 try: 

392 path.unlink() 

393 deleted = True 

394 except OSError: 

395 pass 

396 return deleted 

397 

398 def list_backups(self) -> list[dict[str, Any]]: 

399 """List all available snapshot backups.""" 

400 results = [] 

401 for path in sorted(self.base_dir.glob("snapshot*.json*")): 

402 try: 

403 stat = path.stat() 

404 results.append( 

405 { 

406 "name": path.name, 

407 "size_bytes": stat.st_size, 

408 "mtime": stat.st_mtime, 

409 } 

410 ) 

411 except OSError: 

412 continue 

413 return results