Coverage for src / harnessutils / manager.py: 96%

279 statements  

« prev     ^ index     » next       coverage.py v7.13.2, created at 2026-03-12 18:52 -0600

1"""Main ConversationManager API for harness-utils.""" 

2 

3import time 

4from pathlib import Path 

5from typing import Any 

6 

7from harnessutils.compaction.pruning import PruningDecision, prune_tool_outputs 

8from harnessutils.compaction.summarization import is_overflow, summarize_conversation 

9from harnessutils.compaction.truncation import truncate_output 

10from harnessutils.config import HarnessConfig, StorageConfig 

11from harnessutils.conversion.to_model import to_model_messages 

12from harnessutils.hooks import MessageHooks 

13from harnessutils.inspection import ContextInspector 

14from harnessutils.memory import SemanticMemoryBackend 

15from harnessutils.models.conversation import Conversation 

16from harnessutils.models.message import Message 

17from harnessutils.models.usage import Usage 

18from harnessutils.snapshots import Snapshot, SnapshotDiff, SnapshotManager 

19from harnessutils.storage.filesystem import FilesystemStorage 

20from harnessutils.storage.memory import MemoryStorage 

21from harnessutils.types import LLMClient, StorageBackend 

22from harnessutils.utils.ids import generate_id 

23from harnessutils.workspace import resolve_workspace 

24 

25 

26class ConversationManager: 

27 """Main interface for managing conversations with context window management. 

28 

29 Provides high-level API for: 

30 - Creating and managing conversations 

31 - Adding messages 

32 - Automatic context compaction (truncation, pruning, summarization) 

33 - Message storage and retrieval 

34 """ 

35 

36 def __init__( 

37 self, 

38 storage: StorageBackend | None = None, 

39 config: HarnessConfig | None = None, 

40 *, 

41 workspace_root: Path | str | None = None, 

42 semantic_memory: SemanticMemoryBackend | None = None, 

43 message_hooks: MessageHooks | None = None, 

44 ): 

45 """Initialize conversation manager. 

46 

47 Args: 

48 storage: Storage backend (uses in-memory if None) 

49 config: Configuration (uses defaults if None) 

50 workspace_root: Optional workspace root. Creates .harness/ dir with stable 

51 project UUID. Defaults storage to FilesystemStorage under .harness/sessions/. 

52 semantic_memory: Optional semantic memory backend. Stored as attribute; 

53 harness-utils does not call it internally. 

54 message_hooks: Optional hooks for the add_message() lifecycle. 

55 on_before_add_message fires before storage and can modify or reject. 

56 on_after_add_message fires after storage for side effects. 

57 """ 

58 self.config = config or HarnessConfig() 

59 self.project_id: str | None = None 

60 self.semantic_memory = semantic_memory 

61 

62 if workspace_root is not None: 

63 harness_dir, self.project_id = resolve_workspace(Path(workspace_root)) 

64 if storage is None: 

65 sessions_path = harness_dir / "sessions" 

66 storage_config = StorageConfig(base_path=sessions_path) 

67 storage = FilesystemStorage(storage_config) 

68 

69 self.storage = storage or MemoryStorage() 

70 self.message_hooks = message_hooks 

71 self._message_cache: dict[str, list[Message]] = {} 

72 # Pass storage (not self.storage) so SnapshotManager receives None when no 

73 # explicit backend was given — it falls back to its internal in-memory dict. 

74 self.snapshot_manager = SnapshotManager(storage) 

75 

76 def create_conversation( 

77 self, 

78 conversation_id: str | None = None, 

79 project_id: str | None = None, 

80 ) -> Conversation: 

81 """Create a new conversation. 

82 

83 Args: 

84 conversation_id: Optional conversation ID (generated if None) 

85 project_id: Optional project ID for grouping 

86 

87 Returns: 

88 New conversation object 

89 """ 

90 if conversation_id is None: 

91 conversation_id = generate_id("conv") 

92 

93 now = int(time.time() * 1000) 

94 conversation = Conversation( 

95 id=conversation_id, 

96 project_id=project_id, 

97 created=now, 

98 updated=now, 

99 ) 

100 

101 self.storage.save_conversation(conversation_id, conversation.to_dict()) 

102 self._message_cache[conversation_id] = [] 

103 

104 return conversation 

105 

106 def add_message(self, conversation_id: str, message: Message) -> None: 

107 """Add a message to a conversation. 

108 

109 If message_hooks.on_before_add_message is set, it is called first. 

110 Its return value replaces the message that will be stored. Raising 

111 from it rejects the message entirely (nothing is stored). 

112 

113 If message_hooks.on_after_add_message is set, it is called after 

114 successful storage. Its return value is ignored. 

115 

116 Args: 

117 conversation_id: Conversation to add message to 

118 message: Message to add 

119 """ 

120 # Pre-hook — may modify message or raise to reject 

121 if self.message_hooks and self.message_hooks.on_before_add_message: 

122 message = self.message_hooks.on_before_add_message(conversation_id, message) 

123 

124 # Inject timestamp if not set — required for cleanup_stale_data to work 

125 if "timestamp" not in message.metadata: 

126 message.metadata["timestamp"] = int(time.time() * 1000) 

127 

128 self.storage.save_message(conversation_id, message.id, message.to_dict()) 

129 

130 if conversation_id not in self._message_cache: 

131 self._message_cache[conversation_id] = [] 

132 self._message_cache[conversation_id].append(message) 

133 

134 # Load conversation and update 

135 conv_data = self.storage.load_conversation(conversation_id) 

136 conv = Conversation.from_dict(conv_data) 

137 conv.updated = int(time.time() * 1000) 

138 

139 # Track velocity if message has token count 

140 if message.tokens: 

141 tokens_added = message.tokens.total 

142 conv.update_velocity(tokens_added) 

143 

144 self.storage.save_conversation(conversation_id, conv.to_dict()) 

145 

146 # Post-hook — side effects only, message already persisted 

147 if self.message_hooks and self.message_hooks.on_after_add_message: 

148 self.message_hooks.on_after_add_message(conversation_id, message) 

149 

150 def get_messages(self, conversation_id: str) -> list[Message]: 

151 """Get all messages for a conversation. 

152 

153 Args: 

154 conversation_id: Conversation ID 

155 

156 Returns: 

157 List of messages in chronological order 

158 """ 

159 if conversation_id in self._message_cache: 

160 return self._message_cache[conversation_id] 

161 

162 message_ids = self.storage.list_messages(conversation_id) 

163 messages = [ 

164 Message.from_dict(self.storage.load_message(conversation_id, msg_id)) 

165 for msg_id in message_ids 

166 ] 

167 

168 self._message_cache[conversation_id] = messages 

169 return messages 

170 

171 def query_messages( 

172 self, 

173 conversation_id: str, 

174 limit: int | None = None, 

175 offset: int = 0, 

176 order: str = "asc", 

177 after: int | None = None, 

178 before: int | None = None, 

179 filter: dict[str, Any] | None = None, 

180 ) -> list[Message]: 

181 """Query messages with filtering and pagination. 

182 

183 Args: 

184 conversation_id: Conversation to query 

185 limit: Maximum messages to return 

186 offset: Skip first N messages 

187 order: "asc" (oldest first) or "desc" (newest first) 

188 after: Unix ms timestamp - messages after this time 

189 before: Unix ms timestamp - messages before this time 

190 filter: Filter criteria dict: 

191 - has_errors (bool): Only messages with errors 

192 - has_warnings (bool): Only messages with warnings 

193 - min_importance (float): Minimum importance score 

194 - tools (list[str]): Specific tool types 

195 - roles (list[str]): Message roles (user/assistant) 

196 - has_tool_outputs (bool): Only messages with tool outputs 

197 - is_summary (bool): Only summary messages 

198 

199 Returns: 

200 Filtered and paginated messages 

201 

202 Examples: 

203 # Get last 10 messages 

204 messages = manager.query_messages(conv_id, limit=10, order="desc") 

205 

206 # Get messages with errors 

207 messages = manager.query_messages( 

208 conv_id, 

209 filter={"has_errors": True} 

210 ) 

211 

212 # Get recent high-importance messages 

213 messages = manager.query_messages( 

214 conv_id, 

215 filter={"min_importance": 50.0}, 

216 after=timestamp_24h_ago, 

217 limit=20 

218 ) 

219 """ 

220 from harnessutils.query import MessageFilter, QueryOptions, query_messages 

221 

222 # Build filter 

223 msg_filter = None 

224 if filter is not None: 

225 msg_filter = MessageFilter( 

226 has_errors=filter.get("has_errors"), 

227 has_warnings=filter.get("has_warnings"), 

228 min_importance=filter.get("min_importance"), 

229 tools=filter.get("tools"), 

230 roles=filter.get("roles"), 

231 has_tool_outputs=filter.get("has_tool_outputs"), 

232 is_summary=filter.get("is_summary"), 

233 ) 

234 

235 # Build query options 

236 options = QueryOptions( 

237 limit=limit, 

238 offset=offset, 

239 order=order, # type: ignore 

240 after=after, 

241 before=before, 

242 filter=msg_filter, 

243 ) 

244 

245 # Get all messages and query 

246 messages = self.get_messages(conversation_id) 

247 return query_messages(messages, options) 

248 

249 def get_context_summary( 

250 self, conversation_id: str, recent_limit: int = 5 

251 ) -> dict[str, Any]: 

252 """Get lightweight context summary without loading full content. 

253 

254 Provides overview of conversation state including: 

255 - Message count and token usage 

256 - Summary messages 

257 - Recent activity (last N messages) 

258 - Key messages (high importance) 

259 - Error messages 

260 

261 Args: 

262 conversation_id: Conversation to summarize 

263 recent_limit: Number of recent messages to include (default: 5) 

264 

265 Returns: 

266 Dictionary with context summary: 

267 - conversation_id (str) 

268 - message_count (int) 

269 - total_tokens (int) 

270 - summaries (list): Summary messages 

271 - recent_activity (list): Last N message summaries 

272 - key_messages (list): High-importance messages 

273 - errors (list): Error messages 

274 

275 Examples: 

276 # Get quick overview 

277 summary = manager.get_context_summary(conv_id) 

278 print(f"Messages: {summary['message_count']}") 

279 print(f"Tokens: {summary['total_tokens']}") 

280 print(f"Errors: {len(summary['errors'])}") 

281 """ 

282 from harnessutils.query import build_context_summary 

283 

284 messages = self.get_messages(conversation_id) 

285 summary = build_context_summary(conversation_id, messages, recent_limit) 

286 return summary.to_dict() 

287 

288 def inspect_context(self, conversation_id: str) -> ContextInspector: 

289 """Create inspector for querying context state. 

290 

291 Provides observability into: 

292 - What's currently in context 

293 - Token counts and breakdowns 

294 - Pruning predictions 

295 - Audit trail of decisions 

296 

297 Args: 

298 conversation_id: Conversation to inspect 

299 

300 Returns: 

301 ContextInspector instance with full query capabilities 

302 

303 Example: 

304 >>> inspector = manager.inspect_context(conv_id) 

305 >>> summary = inspector.summary() 

306 >>> print(f"Total tokens: {summary['total_tokens']}") 

307 >>> impact = inspector.predict_impact(5000) 

308 >>> if impact['would_trigger_pruning']: 

309 ... print(f"Would prune ~{impact['estimated_pruned_count']} outputs") 

310 """ 

311 messages = self.get_messages(conversation_id) 

312 

313 # Load conversation metadata 

314 conv_data = self.storage.load_conversation(conversation_id) 

315 conversation = Conversation.from_dict(conv_data) 

316 

317 # Load persisted pruning decisions for audit trail 

318 decisions_data = conversation.metadata.get("latest_pruning_decisions", []) 

319 decisions = [PruningDecision.from_dict(d) for d in decisions_data] 

320 

321 return ContextInspector(messages, self.config, conversation, decisions) 

322 

323 def prune_before_turn( 

324 self, 

325 conversation_id: str, 

326 auto_mode: bool = False, 

327 ) -> dict[str, Any]: 

328 """Proactively prune old tool outputs before processing a turn. 

329 

330 This is Tier 2 compaction - removes old tool outputs while 

331 preserving conversation structure. 

332 

333 Args: 

334 conversation_id: Conversation to prune 

335 auto_mode: Whether this was auto-triggered 

336 

337 Returns: 

338 Detailed pruning result with token tracking and breakdown: 

339 - pruned: Total outputs removed 

340 - tokens_saved: Total tokens saved 

341 - tokens_before: Token count before pruning 

342 - tokens_after: Token count after pruning 

343 - duplicates_pruned: Outputs removed due to duplication 

344 - importance_pruned: Outputs removed due to low importance 

345 - duplicate_tokens_saved: Tokens saved from deduplication 

346 - importance_tokens_saved: Tokens saved from importance pruning 

347 - reduction_percent: Percentage reduction in token usage 

348 """ 

349 if not self.config.compaction.prune and auto_mode: 

350 return { 

351 "pruned": 0, 

352 "tokens_saved": 0, 

353 "tokens_before": 0, 

354 "tokens_after": 0, 

355 "duplicates_pruned": 0, 

356 "importance_pruned": 0, 

357 "duplicate_tokens_saved": 0, 

358 "importance_tokens_saved": 0, 

359 "reduction_percent": 0, 

360 } 

361 

362 messages = self.get_messages(conversation_id) 

363 result = prune_tool_outputs( 

364 messages, 

365 self.config.pruning, 

366 ) 

367 

368 for msg in messages: 

369 self.storage.save_message(conversation_id, msg.id, msg.to_dict()) 

370 

371 # Update cache with pruned messages — parts are preserved since 

372 # prune_tool_outputs only clears tool output strings in-place 

373 self._message_cache[conversation_id] = messages 

374 

375 # Persist decisions so audit trail and quality metrics work 

376 conv_data = self.storage.load_conversation(conversation_id) 

377 conv = Conversation.from_dict(conv_data) 

378 conv.metadata["latest_pruning_decisions"] = [d.to_dict() for d in result.decisions] 

379 self.storage.save_conversation(conversation_id, conv.to_dict()) 

380 

381 return result.to_dict() 

382 

383 def predict_overflow( 

384 self, 

385 conversation_id: str, 

386 current_usage: Usage, 

387 ) -> bool: 

388 """Predict if conversation will overflow in next N turns. 

389 

390 Args: 

391 conversation_id: Conversation to check 

392 current_usage: Current token usage 

393 

394 Returns: 

395 True if overflow predicted within lookahead window 

396 """ 

397 if not self.config.compaction.use_predictive: 

398 return False 

399 

400 # Load conversation and get velocity 

401 conv_data = self.storage.load_conversation(conversation_id) 

402 conv = Conversation.from_dict(conv_data) 

403 velocity = conv.get_velocity() 

404 

405 if velocity is None or not velocity.turn_deltas: 

406 return False # No velocity data yet 

407 

408 # Project tokens ahead 

409 lookahead = self.config.compaction.predictive_lookahead 

410 predicted_growth = velocity.predict_tokens_ahead(lookahead) 

411 

412 # Calculate current total and projected total 

413 current_total = current_usage.input + current_usage.cache.read 

414 projected_total = current_total + predicted_growth 

415 

416 # Check against safety margin 

417 usable_space = ( 

418 self.config.model_limits.default_context_limit 

419 - self.config.model_limits.default_output_limit 

420 ) 

421 safety_threshold = usable_space * self.config.compaction.predictive_safety_margin 

422 

423 return projected_total > safety_threshold 

424 

425 def needs_compaction( 

426 self, 

427 conversation_id: str, 

428 usage: Usage, 

429 ) -> bool: 

430 """Check if conversation needs summarization (Tier 3). 

431 

432 Uses both reactive (overflow) and predictive checks. 

433 

434 Args: 

435 conversation_id: Conversation to check 

436 usage: Token usage from last turn 

437 

438 Returns: 

439 True if summarization needed 

440 """ 

441 # Reactive check: already overflowed 

442 if is_overflow( 

443 usage, 

444 self.config.model_limits.default_context_limit, 

445 self.config.model_limits.default_output_limit, 

446 ): 

447 return True 

448 

449 # Predictive check: will overflow soon 

450 return self.predict_overflow(conversation_id, usage) 

451 

452 def compact( 

453 self, 

454 conversation_id: str, 

455 llm_client: LLMClient, 

456 parent_message_id: str, 

457 model: str | None = None, 

458 auto_mode: bool = False, 

459 ) -> dict[str, Any]: 

460 """Compact conversation using LLM summarization (Tier 3). 

461 

462 Args: 

463 conversation_id: Conversation to compact 

464 llm_client: LLM client for summarization 

465 parent_message_id: Message that triggered compaction 

466 model: Optional model to use for summarization 

467 auto_mode: Whether this was auto-triggered 

468 

469 Returns: 

470 Compaction result with summary message and metrics 

471 """ 

472 if not self.config.compaction.auto and auto_mode: 

473 return {"summarized": False} 

474 

475 messages = self.get_messages(conversation_id) 

476 summary_id = generate_id("msg") 

477 

478 result = summarize_conversation( 

479 messages=messages, 

480 llm_client=llm_client, 

481 parent_message_id=parent_message_id, 

482 message_id=summary_id, 

483 model=model, 

484 auto_mode=auto_mode, 

485 config=self.config.summarization, 

486 ) 

487 

488 self.add_message(conversation_id, result.summary_message) 

489 

490 return { 

491 "summarized": True, 

492 "summary_message_id": summary_id, 

493 "tokens_used": result.tokens_used.total, 

494 "cost": result.cost, 

495 } 

496 

497 def to_model_format(self, conversation_id: str) -> list[dict[str, Any]]: 

498 """Convert conversation messages to model format for LLM requests. 

499 

500 Args: 

501 conversation_id: Conversation to convert 

502 

503 Returns: 

504 List of messages in model format 

505 """ 

506 messages = self.get_messages(conversation_id) 

507 return to_model_messages(messages) 

508 

509 def calculate_context_usage( 

510 self, 

511 conversation_id: str, 

512 model: str | None = None, 

513 ) -> int: 

514 """Calculate exact token count for conversation using tiktoken. 

515 

516 This counts ALL tokens in the conversation (user messages, assistant 

517 responses, tool outputs, etc.) that will be sent to the model. 

518 

519 Uses cl100k_base tokenizer (GPT-4/Claude) which works for most modern LLMs. 

520 

521 Args: 

522 conversation_id: Conversation to calculate usage for 

523 model: Optional model name (currently unused, defaults to cl100k_base) 

524 

525 Returns: 

526 Exact token count that will be used in context window 

527 """ 

528 from harnessutils.tokens.exact import count_tokens_exact 

529 

530 messages = self.to_model_format(conversation_id) 

531 return count_tokens_exact(messages, model) 

532 

533 def get_tool_output_tokens(self, conversation_id: str) -> dict[str, Any]: 

534 """Get detailed breakdown of token usage for tool outputs. 

535 

536 Args: 

537 conversation_id: Conversation to analyze 

538 

539 Returns: 

540 Dictionary with token breakdown: 

541 - total: Total tokens in tool outputs 

542 - by_tool: Token count per tool type 

543 - prunable: Tokens that could be pruned 

544 - protected: Tokens in protected outputs 

545 """ 

546 from harnessutils.compaction.pruning import calculate_context_tokens 

547 

548 messages = self.get_messages(conversation_id) 

549 

550 total = calculate_context_tokens(messages) 

551 by_tool: dict[str, int] = {} 

552 prunable = 0 

553 protected = 0 

554 turns_skipped = 0 

555 

556 for msg in reversed(messages): 

557 if msg.role == "user": 

558 turns_skipped += 1 

559 

560 for part in msg.parts: 

561 from harnessutils.models.parts import ToolPart 

562 from harnessutils.tokens.exact import count_tokens_fast 

563 

564 if not isinstance(part, ToolPart): 

565 continue 

566 

567 if part.state.status != "completed": 

568 continue 

569 

570 if not part.state.output: 

571 continue 

572 

573 tokens = count_tokens_fast(part.state.output) 

574 

575 # Track by tool type 

576 by_tool[part.tool] = by_tool.get(part.tool, 0) + tokens 

577 

578 # Determine if prunable 

579 is_protected = ( 

580 turns_skipped < self.config.pruning.protect_turns 

581 or part.tool in self.config.pruning.protected_tools 

582 or (part.state.time and part.state.time.compacted) 

583 ) 

584 

585 if is_protected: 

586 protected += tokens 

587 else: 

588 prunable += tokens 

589 

590 return { 

591 "total": total, 

592 "by_tool": by_tool, 

593 "prunable": prunable, 

594 "protected": protected, 

595 "prunability_percent": round((prunable / total * 100) if total > 0 else 0, 1), 

596 } 

597 

598 def get_context_quality(self, conversation_id: str) -> dict[str, Any]: 

599 """Get current quality metrics for conversation. 

600 

601 Calculates all quality metrics, updates history, and returns assessment 

602 with health status and actionable recommendations. 

603 

604 Args: 

605 conversation_id: Conversation to analyze 

606 

607 Returns: 

608 Dictionary with all metrics, health status, and recommendations. 

609 Keys: information_density, redundancy_ratio, staleness_score, 

610 error_preservation_rate, protected_ratio, health, recommendations 

611 """ 

612 from harnessutils.quality import assess_quality 

613 

614 conv_data = self.storage.load_conversation(conversation_id) 

615 conv = Conversation.from_dict(conv_data) 

616 messages = self.get_messages(conversation_id) 

617 

618 # Get pruning decisions from metadata if available 

619 decisions = conv.metadata.get("latest_pruning_decisions") 

620 

621 snapshot = assess_quality( 

622 messages=messages, 

623 config=self.config.pruning, 

624 decisions=decisions, 

625 ) 

626 

627 # Update history 

628 conv.update_quality_history(snapshot) 

629 self.storage.save_conversation(conversation_id, conv.to_dict()) 

630 

631 return snapshot.to_dict() 

632 

633 def track_quality_metric( 

634 self, 

635 conversation_id: str, 

636 metric_name: str, 

637 value: float, 

638 timestamp: int | None = None, 

639 ) -> None: 

640 """Track a single quality metric value. 

641 

642 Note: Prefer get_context_quality() which calculates all metrics. 

643 This is for custom/external metrics. 

644 

645 Args: 

646 conversation_id: Conversation to track 

647 metric_name: Name of metric (e.g., "information_density") 

648 value: Metric value 

649 timestamp: Unix ms timestamp (defaults to now) 

650 """ 

651 from harnessutils.quality import QualitySnapshot 

652 

653 if timestamp is None: 

654 timestamp = int(time.time() * 1000) 

655 

656 conv_data = self.storage.load_conversation(conversation_id) 

657 conv = Conversation.from_dict(conv_data) 

658 

659 # Create minimal snapshot with just this metric 

660 # (Other metrics set to 0.0, empty recommendations) 

661 snapshot = QualitySnapshot( 

662 timestamp=timestamp, 

663 information_density=value if metric_name == "information_density" else 0.0, 

664 redundancy_ratio=value if metric_name == "redundancy_ratio" else 0.0, 

665 staleness_score=value if metric_name == "staleness_score" else 0.0, 

666 error_preservation_rate=value 

667 if metric_name == "error_preservation_rate" 

668 else 0.0, 

669 protected_ratio=value if metric_name == "protected_ratio" else 0.0, 

670 health="unknown", 

671 recommendations=[], 

672 ) 

673 

674 conv.update_quality_history(snapshot) 

675 self.storage.save_conversation(conversation_id, conv.to_dict()) 

676 

677 def get_quality_trend( 

678 self, 

679 conversation_id: str, 

680 metric: str, 

681 window: int = 20, 

682 ) -> list[tuple[int, float]]: 

683 """Get trend data for a specific metric. 

684 

685 Args: 

686 conversation_id: Conversation to query 

687 metric: Metric name (e.g., "information_density") 

688 window: Number of most recent snapshots to return 

689 

690 Returns: 

691 List of (timestamp, value) tuples, most recent first 

692 """ 

693 conv_data = self.storage.load_conversation(conversation_id) 

694 conv = Conversation.from_dict(conv_data) 

695 

696 history = conv.get_quality_history() 

697 if history is None: 

698 return [] 

699 

700 return history.get_trend(metric, window) 

701 

702 def truncate_tool_output( 

703 self, 

704 output: str, 

705 tool_name: str, 

706 ) -> str: 

707 """Truncate tool output if it exceeds limits (Tier 1). 

708 

709 Args: 

710 output: Tool output to truncate 

711 tool_name: Name of the tool 

712 

713 Returns: 

714 Potentially truncated output 

715 """ 

716 output_id = generate_id(f"output_{tool_name}") 

717 

718 result = truncate_output( 

719 output=output, 

720 config=self.config.truncation, 

721 output_id=output_id, 

722 ) 

723 

724 if result.truncated and result.output_path: 

725 self.storage.save_truncated_output(result.output_path, output) 

726 

727 return result.content 

728 

729 def create_snapshot( 

730 self, 

731 conversation_id: str, 

732 snapshot_id: str | None = None, 

733 metadata: dict[str, Any] | None = None, 

734 ) -> Snapshot: 

735 """Create snapshot of conversation state for reproducibility. 

736 

737 Captures full conversation state including: 

738 - All messages with content 

739 - Conversation metadata (velocity, etc.) 

740 - Current configuration 

741 

742 Useful for: 

743 - Debugging (save state before/after changes) 

744 - A/B testing (compare different strategies) 

745 - Reproducibility (restore exact state) 

746 

747 Args: 

748 conversation_id: Conversation to snapshot 

749 snapshot_id: Optional snapshot ID (auto-generated if None) 

750 metadata: Optional metadata (e.g., {"reason": "before_refactor"}) 

751 

752 Returns: 

753 Created snapshot 

754 

755 Example: 

756 >>> snap = manager.create_snapshot(conv_id, metadata={"test": "baseline"}) 

757 >>> # Make changes... 

758 >>> snap2 = manager.create_snapshot(conv_id, metadata={"test": "optimized"}) 

759 >>> diff = manager.compare_snapshots(snap.snapshot_id, snap2.snapshot_id) 

760 """ 

761 messages = self.get_messages(conversation_id) 

762 

763 conv_data = self.storage.load_conversation(conversation_id) 

764 conversation = Conversation.from_dict(conv_data) 

765 

766 # Serialize config to dict recursively (convert dataclasses, Paths, etc.) 

767 from dataclasses import asdict, is_dataclass 

768 from pathlib import Path 

769 

770 def _serialize_config(obj: Any) -> Any: 

771 """Recursively convert config to JSON-safe dict.""" 

772 if isinstance(obj, Path): 

773 return str(obj) 

774 elif is_dataclass(obj) and not isinstance(obj, type): 

775 # Convert dataclass instance to dict, then recursively serialize values 

776 obj_dict = asdict(obj) 

777 return {k: _serialize_config(v) for k, v in obj_dict.items()} 

778 elif isinstance(obj, dict): 

779 return {k: _serialize_config(v) for k, v in obj.items()} 

780 elif isinstance(obj, (list, tuple)): 

781 return [_serialize_config(item) for item in obj] 

782 else: 

783 return obj 

784 

785 config_dict = _serialize_config(self.config) 

786 

787 return self.snapshot_manager.create_snapshot( 

788 conversation_id=conversation_id, 

789 messages=messages, 

790 conversation=conversation, 

791 config=config_dict, 

792 snapshot_id=snapshot_id, 

793 metadata=metadata, 

794 ) 

795 

796 def restore_snapshot(self, snapshot_id: str) -> str: 

797 """Restore conversation from snapshot. 

798 

799 WARNING: This replaces current conversation state with snapshot state. 

800 Consider creating a snapshot of current state first. 

801 

802 Args: 

803 snapshot_id: Snapshot to restore 

804 

805 Returns: 

806 Conversation ID of restored conversation 

807 

808 Raises: 

809 FileNotFoundError: If snapshot not found 

810 """ 

811 snapshot = self.snapshot_manager.get_snapshot(snapshot_id) 

812 if not snapshot: 

813 raise FileNotFoundError(f"Snapshot {snapshot_id} not found") 

814 

815 messages, conversation = self.snapshot_manager.restore_snapshot(snapshot) 

816 

817 # Clear existing messages (replace entire conversation state) 

818 # For memory storage, clear the messages dict for this conversation 

819 if hasattr(self.storage, "messages"): 

820 if snapshot.conversation_id in self.storage.messages: 

821 self.storage.messages[snapshot.conversation_id] = {} 

822 

823 # Save conversation 

824 self.storage.save_conversation(snapshot.conversation_id, conversation.to_dict()) 

825 

826 # Save messages 

827 for msg in messages: 

828 self.storage.save_message(snapshot.conversation_id, msg.id, msg.to_dict()) 

829 

830 # Clear cache 

831 if snapshot.conversation_id in self._message_cache: 

832 del self._message_cache[snapshot.conversation_id] 

833 

834 return snapshot.conversation_id 

835 

836 def compare_snapshots( 

837 self, snapshot1_id: str, snapshot2_id: str 

838 ) -> SnapshotDiff | None: 

839 """Compare two snapshots to see what changed. 

840 

841 Args: 

842 snapshot1_id: First snapshot ID (earlier) 

843 snapshot2_id: Second snapshot ID (later) 

844 

845 Returns: 

846 SnapshotDiff describing changes: 

847 - messages_added: Number of messages added 

848 - messages_removed: Number of messages removed 

849 - tokens_delta: Change in token count 

850 - message_changes: List of specific changes 

851 - config_changes: Configuration modifications 

852 - metadata_changes: Additional metadata 

853 

854 None if snapshots not found 

855 """ 

856 return self.snapshot_manager.compare_snapshots(snapshot1_id, snapshot2_id) 

857 

858 def export_snapshot(self, snapshot_id: str, file_path: str) -> None: 

859 """Export snapshot to JSON file for version control. 

860 

861 Args: 

862 snapshot_id: Snapshot to export 

863 file_path: Path to write JSON file 

864 

865 Raises: 

866 FileNotFoundError: If snapshot not found 

867 

868 Example: 

869 >>> snap = manager.create_snapshot(conv_id) 

870 >>> manager.export_snapshot(snap.snapshot_id, "snapshots/baseline.json") 

871 >>> # Commit to git for reproducibility 

872 """ 

873 self.snapshot_manager.export_snapshot(snapshot_id, file_path) 

874 

875 def import_snapshot(self, file_path: str) -> Snapshot: 

876 """Import snapshot from JSON file. 

877 

878 Args: 

879 file_path: Path to JSON file 

880 

881 Returns: 

882 Imported snapshot 

883 """ 

884 return self.snapshot_manager.import_snapshot(file_path) 

885 

886 def cleanup_stale_data( 

887 self, 

888 conversation_id: str, 

889 max_age_hours: float = 24, 

890 keep_errors: bool = True, 

891 execute: bool = False, 

892 ) -> dict[str, Any]: 

893 """Clean up stale conversation data. 

894 

895 By default, identifies old messages and tool outputs for cleanup based on age 

896 without making any changes. Set execute=True to actually clear stale outputs. 

897 

898 Args: 

899 conversation_id: Conversation to clean up 

900 max_age_hours: Maximum age in hours before considering stale (default: 24) 

901 keep_errors: Whether to preserve error messages (default: True) 

902 execute: When True, actually clears stale outputs (default: False) 

903 

904 Returns: 

905 Dictionary with cleanup statistics: 

906 - messages_archived (int): Messages eligible for archival 

907 - tokens_freed (int): Tokens that would be freed 

908 - duplicates_removed (int): Duplicate outputs found 

909 - stale_outputs_pruned (int): Stale outputs identified/cleared 

910 - operations (list[str]): Description of operations 

911 

912 Example: 

913 >>> result = manager.cleanup_stale_data(conv_id, max_age_hours=48) 

914 >>> print(f"Can free {result['tokens_freed']} tokens") 

915 >>> result = manager.cleanup_stale_data(conv_id, max_age_hours=48, execute=True) 

916 >>> print(f"Freed {result['tokens_freed']} tokens") 

917 """ 

918 from harnessutils.maintenance import cleanup_stale_data 

919 

920 messages = self.get_messages(conversation_id) 

921 result = cleanup_stale_data( 

922 messages=messages, 

923 config=self.config.pruning, 

924 max_age_hours=max_age_hours, 

925 keep_errors=keep_errors, 

926 execute=execute, 

927 ) 

928 

929 if execute: 

930 for msg in messages: 

931 self.storage.save_message(conversation_id, msg.id, msg.to_dict()) 

932 # Invalidate cache — outputs were cleared in-place 

933 if conversation_id in self._message_cache: 

934 del self._message_cache[conversation_id] 

935 

936 return result.to_dict() 

937 

938 def scan_and_deduplicate(self, conversation_id: str) -> dict[str, Any]: 

939 """Scan for duplicate outputs and return statistics. 

940 

941 Identifies duplicate tool outputs without removing them. 

942 Use prune_before_turn() to actually remove duplicates. 

943 

944 Args: 

945 conversation_id: Conversation to scan 

946 

947 Returns: 

948 Dictionary with deduplication statistics: 

949 - duplicates_removed (int): Number of duplicates found 

950 - tokens_freed (int): Tokens that would be freed 

951 - operations (list[str]): Description of findings 

952 

953 Example: 

954 >>> result = manager.scan_and_deduplicate(conv_id) 

955 >>> if result['duplicates_removed'] > 0: 

956 ... print(f"Found {result['duplicates_removed']} duplicates") 

957 ... # Run compaction to actually remove them 

958 ... manager.prune_before_turn(conv_id) 

959 """ 

960 from harnessutils.maintenance import scan_and_deduplicate 

961 

962 messages = self.get_messages(conversation_id) 

963 result = scan_and_deduplicate( 

964 messages=messages, 

965 config=self.config.pruning, 

966 ) 

967 

968 return result.to_dict() 

969 

970 def get_memory(self, project_id: str, key: str, default: Any = None) -> Any: 

971 """Get a value from project-scoped memory. 

972 

973 Args: 

974 project_id: Project to retrieve memory from 

975 key: Memory key 

976 default: Default value if key not found 

977 

978 Returns: 

979 Stored value or default 

980 """ 

981 data = self._load_project_memory(project_id) 

982 return data.get(key, default) 

983 

984 def set_memory(self, project_id: str, key: str, value: Any) -> None: 

985 """Set a value in project-scoped memory. 

986 

987 Args: 

988 project_id: Project to store memory in 

989 key: Memory key 

990 value: Value to store 

991 """ 

992 data = self._load_project_memory(project_id) 

993 data[key] = value 

994 try: 

995 self.storage.save_project_memory(project_id, data) 

996 except AttributeError: 

997 pass # Backend doesn't support project memory 

998 

999 def delete_memory(self, project_id: str, key: str) -> None: 

1000 """Delete a key from project-scoped memory. 

1001 

1002 Args: 

1003 project_id: Project to delete memory from 

1004 key: Memory key to delete 

1005 """ 

1006 data = self._load_project_memory(project_id) 

1007 data.pop(key, None) 

1008 try: 

1009 self.storage.save_project_memory(project_id, data) 

1010 except AttributeError: 

1011 pass # Backend doesn't support project memory 

1012 

1013 def list_memory(self, project_id: str) -> dict[str, Any]: 

1014 """List all memory keys and values for a project. 

1015 

1016 Args: 

1017 project_id: Project to list memory for 

1018 

1019 Returns: 

1020 Dictionary of all memory key-value pairs 

1021 """ 

1022 return self._load_project_memory(project_id) 

1023 

1024 def _load_project_memory(self, project_id: str) -> dict[str, Any]: 

1025 """Load project memory, returning empty dict on failure. 

1026 

1027 Args: 

1028 project_id: Project to load memory for 

1029 

1030 Returns: 

1031 Memory dictionary (empty if not found or backend unsupported) 

1032 """ 

1033 try: 

1034 return self.storage.load_project_memory(project_id) 

1035 except (FileNotFoundError, AttributeError): 

1036 return {} 

1037 

1038 def detect_context_issues(self, conversation_id: str) -> list[dict[str, Any]]: 

1039 """Detect quality and drift issues in conversation context. 

1040 

1041 Analyzes conversation for common problems: 

1042 - High redundancy (duplicate content) 

1043 - Staleness accumulation (old messages) 

1044 - Low information density 

1045 - Error preservation status 

1046 - Excessive protection (limiting pruning) 

1047 

1048 Args: 

1049 conversation_id: Conversation to analyze 

1050 

1051 Returns: 

1052 List of issue dictionaries, each containing: 

1053 - issue_type (str): Type of issue 

1054 - severity (str): "info", "warning", or "error" 

1055 - description (str): Human-readable description 

1056 - affected_count (int): Number of items affected 

1057 - suggested_fix (str): Recommended action 

1058 - metadata (dict): Additional details 

1059 

1060 Example: 

1061 >>> issues = manager.detect_context_issues(conv_id) 

1062 >>> for issue in issues: 

1063 ... if issue['severity'] == 'warning': 

1064 ... print(f"⚠️ {issue['description']}") 

1065 ... print(f" Fix: {issue['suggested_fix']}") 

1066 """ 

1067 from harnessutils.maintenance import detect_context_issues 

1068 

1069 conv_data = self.storage.load_conversation(conversation_id) 

1070 conv = Conversation.from_dict(conv_data) 

1071 messages = self.get_messages(conversation_id) 

1072 

1073 issues = detect_context_issues( 

1074 messages=messages, 

1075 conversation=conv, 

1076 config=self.config.pruning, 

1077 ) 

1078 

1079 return [issue.to_dict() for issue in issues]