Coverage for agentos/swarm/agent_memory.py: 23%

315 statements  

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

1""" 

2v1.9.7: Agent Memory System — layered memory with context window management. 

3 

4Three-tier memory architecture: 

5- WorkingMemory: current task context, small capacity, fast access 

6- ShortTermMemory: recent N conversation rounds, sliding window with summarization 

7- LongTermMemory: vector-based semantic retrieval for historical knowledge 

8 

9ContextWindowManager: auto-trim/compress context to fit token budgets. 

10""" 

11 

12from __future__ import annotations 

13 

14import json 

15import time 

16import uuid 

17from collections import OrderedDict, deque 

18from dataclasses import dataclass, field 

19from typing import Any 

20 

21 

22@dataclass 

23class MemoryEntry: 

24 """A single memory entry.""" 

25 

26 id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) 

27 content: str = "" 

28 role: str = "system" # system, user, assistant, tool 

29 timestamp: float = field(default_factory=time.time) 

30 importance: float = 0.5 # 0.0-1.0 

31 ttl: float = 0.0 # Time-to-live in seconds, 0 = never expire 

32 metadata: dict[str, Any] = field(default_factory=dict) 

33 embedding: list[float] | None = None # For long-term vector search 

34 summary: str = "" # Compressed version for context window 

35 

36 

37# ── Working Memory ──────────────────────────────────────────────── 

38 

39 

40class WorkingMemory: 

41 """Ultra-fast, small-capacity memory for the current task. 

42 

43 Holds task description, active goals, intermediate results. 

44 Max entries enforced — oldest evicted on overflow. 

45 """ 

46 

47 def __init__(self, max_entries: int = 20): 

48 self.max_entries = max_entries 

49 self._entries: OrderedDict[str, MemoryEntry] = OrderedDict() 

50 self.task_goal: str = "" 

51 self.active_subtask: str = "" 

52 self.scratchpad: dict[str, Any] = {} 

53 

54 def add(self, entry: MemoryEntry) -> None: 

55 self._entries[entry.id] = entry 

56 while len(self._entries) > self.max_entries: 

57 self._entries.popitem(last=False) 

58 

59 def set_task(self, goal: str, subtask: str = "") -> None: 

60 self.task_goal = goal 

61 self.active_subtask = subtask or goal 

62 

63 def get_all(self) -> list[MemoryEntry]: 

64 return list(self._entries.values()) 

65 

66 def get_last(self, n: int = 5) -> list[MemoryEntry]: 

67 return list(self._entries.values())[-n:] 

68 

69 def clear(self) -> None: 

70 self._entries.clear() 

71 self.scratchpad.clear() 

72 

73 def to_context(self, max_tokens: int = 500) -> str: 

74 """Serialize working memory as context string for LLM.""" 

75 parts = [] 

76 if self.task_goal: 

77 parts.append(f"[Task] {self.task_goal}") 

78 if self.active_subtask and self.active_subtask != self.task_goal: 

79 parts.append(f"[SubTask] {self.active_subtask}") 

80 for entry in list(self._entries.values())[-5:]: 

81 content = entry.summary or entry.content 

82 if len(content) > 200: 

83 content = content[:197] + "..." 

84 parts.append(f"[{entry.role}] {content}") 

85 result = "\n".join(parts) 

86 if self._estimate_tokens(result) > max_tokens: 

87 # Truncate from front 

88 lines = result.split("\n") 

89 while lines and self._estimate_tokens("\n".join(lines)) > max_tokens: 

90 lines.pop(0) 

91 result = "\n".join(lines) 

92 return result 

93 

94 def _estimate_tokens(self, text: str) -> int: 

95 """Rough token estimation: ~4 chars per token.""" 

96 return max(1, len(text) // 4) 

97 

98 

99# ── Short-Term Memory ───────────────────────────────────────────── 

100 

101 

102class ShortTermMemory: 

103 """Sliding window of recent conversation rounds. 

104 

105 Auto-summarizes old rounds to maintain a compact window. 

106 Supports importance-based retention. 

107 """ 

108 

109 def __init__( 

110 self, 

111 max_rounds: int = 50, 

112 auto_summarize: bool = True, 

113 summarize_threshold: int = 20, # Summarize when rounds > threshold 

114 keep_recent: int = 10, # Keep N most recent rounds raw 

115 ): 

116 self.max_rounds = max_rounds 

117 self.auto_summarize = auto_summarize 

118 self.summarize_threshold = summarize_threshold 

119 self.keep_recent = keep_recent 

120 

121 self._rounds: deque[list[MemoryEntry]] = deque() 

122 self._summaries: list[str] = [] # Compressed old rounds 

123 self.total_rounds = 0 

124 

125 def add_round(self, entries: list[MemoryEntry]) -> None: 

126 """Add a full conversation round.""" 

127 self._rounds.append(entries) 

128 self.total_rounds += 1 

129 

130 # Enforce max rounds 

131 while len(self._rounds) > self.max_rounds: 

132 evicted = self._rounds.popleft() 

133 if self.auto_summarize: 

134 summary = self._summarize_round(evicted) 

135 if summary: 

136 self._summaries.append(summary) 

137 

138 # Auto-summarize middle rounds when over threshold 

139 if self.auto_summarize and len(self._rounds) > self.summarize_threshold: 

140 self._compress_middle() 

141 

142 def _compress_middle(self) -> None: 

143 """Compress rounds between recent keepers and front.""" 

144 keep_count = min(self.keep_recent, len(self._rounds)) 

145 recent = list(self._rounds)[-keep_count:] 

146 middle = list(self._rounds)[:-keep_count] if keep_count < len(self._rounds) else [] 

147 

148 if not middle: 

149 return 

150 

151 # Summarize middle rounds 

152 for entries in middle: 

153 summary = self._summarize_round(entries) 

154 if summary: 

155 self._summaries.append(summary) 

156 

157 # Replace deque with only recent rounds 

158 self._rounds = deque(recent) 

159 

160 def _summarize_round(self, entries: list[MemoryEntry]) -> str: 

161 """Create a compressed summary of a round.""" 

162 if not entries: 

163 return "" 

164 

165 # Collect key content 

166 parts = [] 

167 for entry in entries: 

168 content = entry.content 

169 if len(content) > 100: 

170 content = content[:97] + "..." 

171 parts.append(f"{entry.role}: {content}") 

172 

173 if not parts: 

174 return "" 

175 

176 timestamp = entries[0].timestamp if entries else time.time() 

177 return f"[Round@{timestamp:.0f}] " + " | ".join(parts) 

178 

179 def get_context( 

180 self, 

181 include_summaries: bool = True, 

182 max_rounds: int = 15, 

183 ) -> list[MemoryEntry]: 

184 """Get flattened context entries.""" 

185 flat: list[MemoryEntry] = [] 

186 

187 # Add summaries as system entries 

188 if include_summaries: 

189 for summary in self._summaries[-3:]: # Keep last 3 summaries 

190 flat.append( 

191 MemoryEntry( 

192 content=f"[History Summary] {summary}", 

193 role="system", 

194 importance=0.3, 

195 ) 

196 ) 

197 

198 # Add recent rounds 

199 recent_rounds = list(self._rounds)[-max_rounds:] 

200 for entries in recent_rounds: 

201 flat.extend(entries) 

202 

203 return flat 

204 

205 def clear(self) -> None: 

206 self._rounds.clear() 

207 self._summaries.clear() 

208 self.total_rounds = 0 

209 

210 

211# ── Long-Term Memory ────────────────────────────────────────────── 

212 

213 

214class LongTermMemory: 

215 """Vector-based semantic memory for historical knowledge retrieval. 

216 

217 Stores important memories with embeddings. Supports cosine-similarity search. 

218 Falls back to keyword search when no embeddings available. 

219 """ 

220 

221 def __init__( 

222 self, 

223 max_entries: int = 10000, 

224 importance_threshold: float = 0.4, # Only store entries above this importance 

225 persist_path: str = "", 

226 ): 

227 self.max_entries = max_entries 

228 self.importance_threshold = importance_threshold 

229 self.persist_path = persist_path 

230 

231 self._entries: dict[str, MemoryEntry] = {} 

232 self._embeddings: dict[str, list[float]] = {} # entry_id → embedding 

233 

234 self._embedder: Any = None # Lazy-loaded embedder 

235 

236 def add(self, entry: MemoryEntry) -> None: 

237 """Store a memory entry. Only stores if importance >= threshold.""" 

238 if entry.importance < self.importance_threshold: 

239 return 

240 

241 self._entries[entry.id] = entry 

242 if entry.embedding: 

243 self._embeddings[entry.id] = entry.embedding 

244 

245 # Evict oldest if over capacity 

246 while len(self._entries) > self.max_entries: 

247 oldest_id = min( 

248 self._entries.keys(), 

249 key=lambda k: self._entries[k].timestamp, 

250 ) 

251 del self._entries[oldest_id] 

252 self._embeddings.pop(oldest_id, None) 

253 

254 def search( 

255 self, 

256 query: str, 

257 top_k: int = 5, 

258 query_embedding: list[float] | None = None, 

259 ) -> list[MemoryEntry]: 

260 """Semantic search over stored memories. 

261 

262 Uses cosine similarity if embeddings available, else keyword overlap. 

263 """ 

264 if self._embeddings and query_embedding: 

265 return self._vector_search(query_embedding, top_k) 

266 return self._keyword_search(query, top_k) 

267 

268 def search_keywords( 

269 self, 

270 keywords: list[str], 

271 top_k: int = 5, 

272 ) -> list[MemoryEntry]: 

273 """Search memories by keyword overlap.""" 

274 results = [] 

275 for entry in self._entries.values(): 

276 content_lower = entry.content.lower() 

277 score = sum(1 for kw in keywords if kw.lower() in content_lower) 

278 if score > 0: 

279 results.append((score, entry)) 

280 results.sort(key=lambda x: (-x[0], -x[1].importance)) 

281 return [entry for _, entry in results[:top_k]] 

282 

283 def search_by_timerange( 

284 self, 

285 start: float, 

286 end: float | None = None, 

287 top_k: int = 10, 

288 ) -> list[MemoryEntry]: 

289 """Search memories by time range.""" 

290 end = end or time.time() 

291 results = [entry for entry in self._entries.values() if start <= entry.timestamp <= end] 

292 results.sort(key=lambda e: e.timestamp, reverse=True) 

293 return results[:top_k] 

294 

295 def _vector_search( 

296 self, 

297 query_emb: list[float], 

298 top_k: int, 

299 ) -> list[MemoryEntry]: 

300 """Cosine similarity search.""" 

301 scores = [] 

302 for eid, emb in self._embeddings.items(): 

303 sim = self._cosine_similarity(query_emb, emb) 

304 scores.append((sim, eid)) 

305 scores.sort(reverse=True) 

306 return [self._entries[eid] for _, eid in scores[:top_k] if eid in self._entries] 

307 

308 def _keyword_search(self, query: str, top_k: int) -> list[MemoryEntry]: 

309 """Fallback keyword overlap search.""" 

310 query_words = set(query.lower().split()) 

311 if not query_words: 

312 return [] 

313 return self.search_keywords(list(query_words), top_k) 

314 

315 def _cosine_similarity(self, a: list[float], b: list[float]) -> float: 

316 """Cosine similarity between two vectors.""" 

317 if len(a) != len(b): 

318 return 0.0 

319 dot = sum(x * y for x, y in zip(a, b)) 

320 norm_a = sum(x * x for x in a) ** 0.5 

321 norm_b = sum(y * y for y in b) ** 0.5 

322 if norm_a == 0 or norm_b == 0: 

323 return 0.0 

324 return dot / (norm_a * norm_b) 

325 

326 def export_important(self, top_k: int = 20) -> list[MemoryEntry]: 

327 """Export most important entries.""" 

328 entries = sorted( 

329 self._entries.values(), 

330 key=lambda e: (e.importance, e.timestamp), 

331 reverse=True, 

332 ) 

333 return entries[:top_k] 

334 

335 def clear(self) -> None: 

336 self._entries.clear() 

337 self._embeddings.clear() 

338 

339 def save(self, path: str = "") -> None: 

340 """Persist to disk (without embeddings).""" 

341 save_path = path or self.persist_path 

342 if not save_path: 

343 return 

344 

345 data = [] 

346 for entry in self._entries.values(): 

347 data.append( 

348 { 

349 "id": entry.id, 

350 "content": entry.content, 

351 "role": entry.role, 

352 "timestamp": entry.timestamp, 

353 "importance": entry.importance, 

354 "metadata": entry.metadata, 

355 } 

356 ) 

357 

358 with open(save_path, "w") as f: 

359 json.dump(data, f, ensure_ascii=False, indent=2) 

360 

361 def load(self, path: str = "") -> int: 

362 """Load from disk.""" 

363 load_path = path or self.persist_path 

364 if not load_path: 

365 return 0 

366 

367 try: 

368 with open(load_path) as f: 

369 data = json.load(f) 

370 except (FileNotFoundError, json.JSONDecodeError): 

371 return 0 

372 

373 count = 0 

374 for item in data: 

375 entry = MemoryEntry( 

376 id=item.get("id", uuid.uuid4().hex[:8]), 

377 content=item.get("content", ""), 

378 role=item.get("role", "system"), 

379 timestamp=item.get("timestamp", time.time()), 

380 importance=item.get("importance", 0.5), 

381 metadata=item.get("metadata", {}), 

382 ) 

383 self._entries[entry.id] = entry 

384 count += 1 

385 

386 return count 

387 

388 

389# ── Context Window Manager ──────────────────────────────────────── 

390 

391 

392@dataclass 

393class ContextBudget: 

394 """Token budget for context window management.""" 

395 

396 total_tokens: int = 4096 # Max total tokens 

397 system_reserved: int = 512 # Reserved for system prompt 

398 working_memory_budget: int = 640 # Budget for working memory 

399 short_term_budget: int = 1536 # Budget for short-term memory 

400 long_term_budget: int = 512 # Budget for injected long-term memories 

401 query_budget: int = 896 # Budget for current query 

402 safety_margin: int = 128 # Safety margin 

403 

404 

405class ContextWindowManager: 

406 """Manages token budgets and assembles context windows. 

407 

408 Automatically trims/compresses content to fit within token budgets. 

409 Handles the three-tier memory system's context assembly. 

410 """ 

411 

412 def __init__(self, budget: ContextBudget | None = None): 

413 self.budget = budget or ContextBudget() 

414 

415 def assemble( 

416 self, 

417 working: WorkingMemory, 

418 short_term: ShortTermMemory, 

419 long_term: LongTermMemory, 

420 current_query: str = "", 

421 retrieval_query: str = "", 

422 ) -> str: 

423 """Assemble a full context window from all memory tiers. 

424 

425 Returns a string ready to prepend to the LLM prompt. 

426 """ 

427 sections = [] 

428 

429 # 1. Working memory context 

430 wm_ctx = working.to_context(max_tokens=self.budget.working_memory_budget) 

431 if wm_ctx: 

432 sections.append(("Working Memory", wm_ctx, self.budget.working_memory_budget)) 

433 

434 # 2. Short-term memory context 

435 st_entries = short_term.get_context(include_summaries=True, max_rounds=15) 

436 st_ctx = self._entries_to_context(st_entries, self.budget.short_term_budget) 

437 if st_ctx: 

438 sections.append(("Recent History", st_ctx, self.budget.short_term_budget)) 

439 

440 # 3. Long-term memory (semantic retrieval) 

441 lt_entries = [] 

442 if retrieval_query: 

443 lt_entries = long_term.search(retrieval_query, top_k=5) 

444 else: 

445 lt_entries = long_term.export_important(top_k=5) 

446 

447 if lt_entries: 

448 lt_ctx = self._entries_to_context(lt_entries, self.budget.long_term_budget) 

449 if lt_ctx: 

450 sections.append(("Relevant Memories", lt_ctx, self.budget.long_term_budget)) 

451 

452 # 4. Current query 

453 query_section = current_query 

454 if query_section: 

455 est_tokens = self._estimate_tokens(query_section) 

456 if est_tokens > self.budget.query_budget: 

457 query_section = self._truncate_text(query_section, self.budget.query_budget) 

458 sections.append(("Current Task", query_section, self.budget.query_budget)) 

459 

460 # Assemble final context 

461 final_parts = [] 

462 for name, content, _ in sections: 

463 final_parts.append(f"--- {name} ---\n{content}") 

464 

465 return "\n\n".join(final_parts) 

466 

467 def fit_to_budget(self, text: str, max_tokens: int) -> str: 

468 """Trim text to fit within token budget.""" 

469 if self._estimate_tokens(text) <= max_tokens: 

470 return text 

471 return self._truncate_text(text, max_tokens) 

472 

473 def _entries_to_context(self, entries: list[MemoryEntry], max_tokens: int) -> str: 

474 """Convert memory entries to context string, fitting budget.""" 

475 if not entries: 

476 return "" 

477 

478 lines = [] 

479 token_count = 0 

480 

481 for entry in entries: 

482 content = entry.summary or entry.content 

483 line = f"[{entry.role}] {content}" 

484 line_tokens = self._estimate_tokens(line) 

485 

486 if token_count + line_tokens > max_tokens: 

487 # Try truncated version 

488 available = max_tokens - token_count - 10 

489 if available > 20: 

490 truncated = content[: available * 4] 

491 line = f"[{entry.role}] {truncated}..." 

492 token_count += self._estimate_tokens(line) 

493 break 

494 

495 lines.append(line) 

496 token_count += line_tokens 

497 

498 return "\n".join(lines) 

499 

500 def _truncate_text(self, text: str, max_tokens: int) -> str: 

501 """Truncate text from the beginning to fit token budget.""" 

502 # Estimate char budget: ~4 chars per token 

503 char_budget = max_tokens * 4 

504 if len(text) <= char_budget: 

505 return text 

506 

507 # Keep last char_budget characters for relevance 

508 return "...(truncated) " + text[-char_budget:] 

509 

510 def _estimate_tokens(self, text: str) -> int: 

511 """Rough token estimation.""" 

512 return max(1, len(text) // 4) 

513 

514 

515# ── Unified Agent Memory ────────────────────────────────────────── 

516 

517 

518class AgentMemory: 

519 """Unified memory system combining all three tiers + context management. 

520 

521 High-level API for agent memory operations: 

522 - Remember conversation rounds 

523 - Retrieve relevant history 

524 - Assemble context window 

525 

526 Usage: 

527 memory = AgentMemory() 

528 memory.add_round([user_msg, assistant_msg]) 

529 context = memory.get_context(query="What files did I create yesterday?") 

530 """ 

531 

532 def __init__( 

533 self, 

534 working_max: int = 20, 

535 short_term_max_rounds: int = 50, 

536 long_term_max: int = 10000, 

537 budget: ContextBudget | None = None, 

538 ): 

539 self.working = WorkingMemory(max_entries=working_max) 

540 self.short_term = ShortTermMemory(max_rounds=short_term_max_rounds) 

541 self.long_term = LongTermMemory(max_entries=long_term_max) 

542 self.window_manager = ContextWindowManager(budget=budget) 

543 

544 def add_round( 

545 self, 

546 entries: list[MemoryEntry], 

547 importance: float = 0.5, 

548 ) -> None: 

549 """Add a full conversation round to memory.""" 

550 self.short_term.add_round(entries) 

551 

552 # Store important entries to long-term 

553 for entry in entries: 

554 if entry.importance >= 0.4: 

555 self.long_term.add(entry) 

556 

557 def set_task(self, goal: str, subtask: str = "") -> None: 

558 """Set current task context in working memory.""" 

559 self.working.set_task(goal, subtask) 

560 

561 def remember( 

562 self, 

563 content: str, 

564 role: str = "system", 

565 importance: float = 0.5, 

566 ttl: float = 0.0, 

567 metadata: dict | None = None, 

568 ) -> MemoryEntry: 

569 """Store a single memory entry.""" 

570 entry = MemoryEntry( 

571 content=content, 

572 role=role, 

573 importance=importance, 

574 ttl=ttl, 

575 metadata=metadata or {}, 

576 ) 

577 self.working.add(entry) 

578 if importance >= 0.6: 

579 self.long_term.add(entry) 

580 return entry 

581 

582 def recall( 

583 self, 

584 query: str, 

585 top_k: int = 5, 

586 include_short_term: bool = True, 

587 include_long_term: bool = True, 

588 ) -> list[MemoryEntry]: 

589 """Search across memory tiers.""" 

590 results: list[MemoryEntry] = [] 

591 

592 if include_short_term: 

593 st_entries = self.short_term.get_context(include_summaries=True) 

594 # Simple keyword filter on short-term 

595 query_words = set(query.lower().split()) 

596 for entry in st_entries: 

597 score = sum(1 for w in query_words if w in entry.content.lower()) 

598 if score > 0: 

599 results.append(entry) 

600 

601 if include_long_term: 

602 lt_results = self.long_term.search(query, top_k=top_k) 

603 for entry in lt_results: 

604 if entry not in results: 

605 results.append(entry) 

606 

607 # Sort by importance then timestamp 

608 results.sort(key=lambda e: (e.importance, e.timestamp), reverse=True) 

609 return results[:top_k] 

610 

611 def get_context(self, query: str = "") -> str: 

612 """Assemble full context window for the current task.""" 

613 return self.window_manager.assemble( 

614 working=self.working, 

615 short_term=self.short_term, 

616 long_term=self.long_term, 

617 current_query=query, 

618 retrieval_query=query, 

619 ) 

620 

621 def clear_working(self) -> None: 

622 self.working.clear() 

623 

624 def clear_all(self) -> None: 

625 self.working.clear() 

626 self.short_term.clear() 

627 self.long_term.clear()