Coverage for src / moai_adk / core / session_manager.py: 0.00%

193 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Session Manager for MoAI-ADK Agent Orchestration 

3 

4Manages sub-agent session IDs and resume logic based on official Claude Code documentation. 

5Provides session tracking, result storage, and resume decision making. 

6 

7Official Documentation Reference: 

8https://code.claude.com/docs/en/sub-agents 

9 

10Key Principles: 

11- Sub-agents operate in isolated context windows 

12- No direct agent-to-agent communication 

13- Results flow through main conversation thread (Alfred) 

14- Resume preserves full conversation history 

15- Each execution gets unique agentId 

16""" 

17 

18import json 

19import logging 

20from datetime import datetime 

21from pathlib import Path 

22from typing import Any, Dict, List, Optional 

23 

24logger = logging.getLogger(__name__) 

25 

26 

27class SessionManager: 

28 """ 

29 Manages sub-agent session IDs and resume logic. 

30 

31 Based on official Claude Code sub-agent pattern: 

32 - Each agent execution gets unique agentId 

33 - Resume parameter inherits full conversation history 

34 - Session transcripts stored in agent-{agentId}.jsonl 

35 

36 Attributes: 

37 _sessions: Mapping of agent_name to current agentId 

38 _results: Storage of agent execution results (agentId → result data) 

39 _chains: Workflow chains tracking (chain_name → [agentIds]) 

40 _session_file: Persistent storage location 

41 _transcript_dir: Directory for conversation transcripts 

42 """ 

43 

44 def __init__( 

45 self, 

46 session_file: Optional[Path] = None, 

47 transcript_dir: Optional[Path] = None, 

48 ): 

49 """ 

50 Initialize SessionManager. 

51 

52 Args: 

53 session_file: Path to session storage JSON file 

54 (default: .moai/memory/agent-sessions.json) 

55 transcript_dir: Directory for agent transcripts 

56 (default: .moai/logs/agent-transcripts/) 

57 """ 

58 # Default paths 

59 project_root = Path.cwd() 

60 self._session_file = ( 

61 session_file or project_root / ".moai" / "memory" / "agent-sessions.json" 

62 ) 

63 self._transcript_dir = ( 

64 transcript_dir or project_root / ".moai" / "logs" / "agent-transcripts" 

65 ) 

66 

67 # Ensure directories exist 

68 self._session_file.parent.mkdir(parents=True, exist_ok=True) 

69 self._transcript_dir.mkdir(parents=True, exist_ok=True) 

70 

71 # In-memory storage 

72 self._sessions: Dict[str, str] = {} # agent_name → current agentId 

73 self._results: Dict[str, Any] = {} # agentId → result data 

74 self._chains: Dict[str, List[str]] = {} # chain_name → [agentIds] 

75 self._metadata: Dict[str, Dict[str, Any]] = {} # agentId → metadata 

76 

77 # Load existing sessions 

78 self._load_sessions() 

79 

80 def _load_sessions(self) -> None: 

81 """Load session data from persistent storage.""" 

82 if self._session_file.exists(): 

83 try: 

84 with open(self._session_file, "r", encoding="utf-8") as f: 

85 data = json.load(f) 

86 self._sessions = data.get("sessions", {}) 

87 self._chains = data.get("chains", {}) 

88 self._metadata = data.get("metadata", {}) 

89 logger.info( 

90 f"Loaded {len(self._sessions)} sessions from {self._session_file}" 

91 ) 

92 except json.JSONDecodeError as e: 

93 logger.warning(f"Failed to load sessions: {e}") 

94 self._sessions = {} 

95 self._chains = {} 

96 self._metadata = {} 

97 

98 def _save_sessions(self) -> None: 

99 """Save session data to persistent storage.""" 

100 data = { 

101 "sessions": self._sessions, 

102 "chains": self._chains, 

103 "metadata": self._metadata, 

104 "last_updated": datetime.now().isoformat(), 

105 } 

106 

107 try: 

108 with open(self._session_file, "w", encoding="utf-8") as f: 

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

110 logger.debug(f"Saved sessions to {self._session_file}") 

111 except IOError as e: 

112 logger.error(f"Failed to save sessions: {e}") 

113 

114 def register_agent_result( 

115 self, 

116 agent_name: str, 

117 agent_id: str, 

118 result: Any, 

119 chain_id: Optional[str] = None, 

120 ) -> None: 

121 """ 

122 Register agent execution result in main context. 

123 

124 This method implements the official pattern: 

125 "Results flow through main conversation thread" 

126 

127 Args: 

128 agent_name: Name of the agent (e.g., "tdd-implementer") 

129 agent_id: Unique agentId returned from Task() execution 

130 result: Result data from agent execution 

131 chain_id: Optional workflow chain identifier (e.g., "SPEC-AUTH-001-implementation") 

132 """ 

133 # Store agent ID mapping 

134 self._sessions[agent_name] = agent_id 

135 

136 # Store result data 

137 self._results[agent_id] = { 

138 "agent_name": agent_name, 

139 "result": result, 

140 "timestamp": datetime.now().isoformat(), 

141 "chain_id": chain_id, 

142 } 

143 

144 # Track in workflow chain 

145 if chain_id: 

146 if chain_id not in self._chains: 

147 self._chains[chain_id] = [] 

148 self._chains[chain_id].append(agent_id) 

149 

150 # Store metadata 

151 self._metadata[agent_id] = { 

152 "agent_name": agent_name, 

153 "created_at": datetime.now().isoformat(), 

154 "chain_id": chain_id, 

155 "resume_count": 0, 

156 } 

157 

158 # Persist to disk 

159 self._save_sessions() 

160 

161 logger.info( 

162 f"Registered agent result: {agent_name} (agentId: {agent_id[:8]}..., chain: {chain_id})" 

163 ) 

164 

165 def get_resume_id( 

166 self, 

167 agent_name: str, 

168 chain_id: Optional[str] = None, 

169 ) -> Optional[str]: 

170 """ 

171 Get agentId to resume if continuing same work. 

172 

173 Official pattern: 

174 - resume parameter preserves full conversation history 

175 - Same agent can continue work with context 

176 

177 Args: 

178 agent_name: Name of the agent to resume 

179 chain_id: Optional workflow chain to resume 

180 

181 Returns: 

182 agentId to resume, or None if should start new session 

183 """ 

184 # Check if agent has previous session 

185 if agent_name not in self._sessions: 

186 logger.debug(f"No previous session for {agent_name}") 

187 return None 

188 

189 agent_id = self._sessions[agent_name] 

190 

191 # Validate chain_id if provided 

192 if chain_id: 

193 metadata = self._metadata.get(agent_id, {}) 

194 if metadata.get("chain_id") != chain_id: 

195 logger.debug( 

196 f"Chain mismatch: {agent_name} was in {metadata.get('chain_id')}, " 

197 f"requested {chain_id}" 

198 ) 

199 return None 

200 

201 logger.info(f"Resume ID for {agent_name}: {agent_id[:8]}...") 

202 return agent_id 

203 

204 def should_resume( 

205 self, 

206 agent_name: str, 

207 current_task: str, 

208 previous_task: Optional[str] = None, 

209 ) -> bool: 

210 """ 

211 Determine if resume or new invocation is appropriate. 

212 

213 Decision logic based on official best practices: 

214 - Resume: Same agent, continuing previous task, context continuity needed 

215 - New: Different agent, independent task, context switch 

216 

217 Args: 

218 agent_name: Name of the agent 

219 current_task: Description of current task 

220 previous_task: Description of previous task (if any) 

221 

222 Returns: 

223 True if should resume, False if should start new session 

224 """ 

225 # No previous session → new 

226 if agent_name not in self._sessions: 

227 return False 

228 

229 # No previous task information → new 

230 if not previous_task: 

231 return False 

232 

233 # Check resume count (prevent infinite loops) 

234 agent_id = self._sessions[agent_name] 

235 metadata = self._metadata.get(agent_id, {}) 

236 resume_count = metadata.get("resume_count", 0) 

237 

238 if resume_count >= 5: # Max resume depth from config 

239 logger.warning( 

240 f"{agent_name} has been resumed {resume_count} times, starting new session" 

241 ) 

242 return False 

243 

244 # Heuristic: Check if tasks are related 

245 # (This can be enhanced with semantic similarity) 

246 task_keywords_match = any( 

247 keyword in current_task.lower() 

248 for keyword in previous_task.lower().split() 

249 if len(keyword) > 4 

250 ) 

251 

252 if task_keywords_match: 

253 logger.info(f"Tasks appear related, resuming {agent_name}") 

254 return True 

255 

256 logger.info(f"Tasks appear independent, starting new session for {agent_name}") 

257 return False 

258 

259 def increment_resume_count(self, agent_id: str) -> None: 

260 """ 

261 Increment resume count for an agent session. 

262 

263 Args: 

264 agent_id: Agent session ID 

265 """ 

266 if agent_id in self._metadata: 

267 self._metadata[agent_id]["resume_count"] += 1 

268 self._metadata[agent_id]["last_resumed_at"] = datetime.now().isoformat() 

269 self._save_sessions() 

270 

271 def get_agent_result(self, agent_id: str) -> Optional[Any]: 

272 """ 

273 Retrieve stored result for an agent execution. 

274 

275 Args: 

276 agent_id: Agent session ID 

277 

278 Returns: 

279 Stored result data, or None if not found 

280 """ 

281 result_data = self._results.get(agent_id) 

282 if result_data: 

283 return result_data["result"] 

284 return None 

285 

286 def get_chain_results(self, chain_id: str) -> List[Dict[str, Any]]: 

287 """ 

288 Get all agent results in a workflow chain. 

289 

290 Args: 

291 chain_id: Workflow chain identifier 

292 

293 Returns: 

294 List of result dictionaries in execution order 

295 """ 

296 if chain_id not in self._chains: 

297 return [] 

298 

299 agent_ids = self._chains[chain_id] 

300 results = [] 

301 

302 for agent_id in agent_ids: 

303 if agent_id in self._results: 

304 results.append(self._results[agent_id]) 

305 

306 return results 

307 

308 def get_chain_summary(self, chain_id: str) -> Dict[str, Any]: 

309 """ 

310 Get summary of a workflow chain. 

311 

312 Args: 

313 chain_id: Workflow chain identifier 

314 

315 Returns: 

316 Summary dictionary with agent names, timestamps, etc. 

317 """ 

318 results = self.get_chain_results(chain_id) 

319 

320 if not results: 

321 return {"chain_id": chain_id, "status": "not_found"} 

322 

323 return { 

324 "chain_id": chain_id, 

325 "agent_count": len(results), 

326 "agents": [r["agent_name"] for r in results], 

327 "started_at": results[0]["timestamp"] if results else None, 

328 "completed_at": results[-1]["timestamp"] if results else None, 

329 "status": "completed", 

330 } 

331 

332 def clear_agent_session(self, agent_name: str) -> None: 

333 """ 

334 Clear session data for a specific agent. 

335 

336 Use when you want to force a new session for an agent. 

337 

338 Args: 

339 agent_name: Name of the agent 

340 """ 

341 if agent_name in self._sessions: 

342 agent_id = self._sessions[agent_name] 

343 del self._sessions[agent_name] 

344 

345 if agent_id in self._results: 

346 del self._results[agent_id] 

347 

348 if agent_id in self._metadata: 

349 del self._metadata[agent_id] 

350 

351 self._save_sessions() 

352 logger.info(f"Cleared session for {agent_name}") 

353 

354 def clear_chain(self, chain_id: str) -> None: 

355 """ 

356 Clear all sessions in a workflow chain. 

357 

358 Args: 

359 chain_id: Workflow chain identifier 

360 """ 

361 if chain_id in self._chains: 

362 agent_ids = self._chains[chain_id] 

363 

364 for agent_id in agent_ids: 

365 if agent_id in self._results: 

366 del self._results[agent_id] 

367 if agent_id in self._metadata: 

368 del self._metadata[agent_id] 

369 

370 del self._chains[chain_id] 

371 self._save_sessions() 

372 logger.info(f"Cleared chain: {chain_id}") 

373 

374 def get_all_sessions(self) -> Dict[str, Any]: 

375 """ 

376 Get all active sessions. 

377 

378 Returns: 

379 Dictionary with all session data 

380 """ 

381 return { 

382 "sessions": self._sessions, 

383 "chains": list(self._chains.keys()), 

384 "total_results": len(self._results), 

385 } 

386 

387 def export_transcript(self, agent_id: str) -> Optional[Path]: 

388 """ 

389 Get path to agent conversation transcript. 

390 

391 Official pattern: 

392 - Transcripts stored in agent-{agentId}.jsonl 

393 - Contains full conversation history 

394 

395 Args: 

396 agent_id: Agent session ID 

397 

398 Returns: 

399 Path to transcript file, or None if not found 

400 """ 

401 transcript_file = self._transcript_dir / f"agent-{agent_id}.jsonl" 

402 

403 if transcript_file.exists(): 

404 return transcript_file 

405 

406 logger.warning(f"Transcript not found for agentId: {agent_id}") 

407 return None 

408 

409 def create_chain( 

410 self, 

411 chain_id: str, 

412 agent_sequence: List[str], 

413 metadata: Optional[Dict[str, Any]] = None, 

414 ) -> None: 

415 """ 

416 Create a new workflow chain. 

417 

418 Args: 

419 chain_id: Unique chain identifier (e.g., "SPEC-AUTH-001-implementation") 

420 agent_sequence: Expected agent execution order 

421 metadata: Optional metadata for the chain 

422 """ 

423 self._chains[chain_id] = [] 

424 

425 chain_metadata = { 

426 "created_at": datetime.now().isoformat(), 

427 "expected_sequence": agent_sequence, 

428 "metadata": metadata or {}, 

429 } 

430 

431 # Store in a separate chains metadata file 

432 chains_file = self._session_file.parent / "workflow-chains.json" 

433 

434 if chains_file.exists(): 

435 with open(chains_file, "r", encoding="utf-8") as f: 

436 chains_data = json.load(f) 

437 else: 

438 chains_data = {} 

439 

440 chains_data[chain_id] = chain_metadata 

441 

442 with open(chains_file, "w", encoding="utf-8") as f: 

443 json.dump(chains_data, f, indent=2, ensure_ascii=False) 

444 

445 logger.info( 

446 f"Created workflow chain: {chain_id} with {len(agent_sequence)} agents" 

447 ) 

448 

449 

450# Global instance (singleton pattern) 

451_session_manager_instance: Optional[SessionManager] = None 

452 

453 

454def get_session_manager() -> SessionManager: 

455 """ 

456 Get global SessionManager instance (singleton). 

457 

458 Returns: 

459 SessionManager instance 

460 """ 

461 global _session_manager_instance 

462 

463 if _session_manager_instance is None: 

464 _session_manager_instance = SessionManager() 

465 

466 return _session_manager_instance 

467 

468 

469# Convenience functions for direct use 

470 

471 

472def register_agent( 

473 agent_name: str, 

474 agent_id: str, 

475 result: Any, 

476 chain_id: Optional[str] = None, 

477) -> None: 

478 """ 

479 Convenience function to register agent result. 

480 

481 Args: 

482 agent_name: Name of the agent 

483 agent_id: Unique agentId from Task() execution 

484 result: Result data 

485 chain_id: Optional workflow chain identifier 

486 """ 

487 manager = get_session_manager() 

488 manager.register_agent_result(agent_name, agent_id, result, chain_id) 

489 

490 

491def get_resume_id(agent_name: str, chain_id: Optional[str] = None) -> Optional[str]: 

492 """ 

493 Convenience function to get resume ID. 

494 

495 Args: 

496 agent_name: Name of the agent 

497 chain_id: Optional workflow chain 

498 

499 Returns: 

500 agentId to resume, or None 

501 """ 

502 manager = get_session_manager() 

503 return manager.get_resume_id(agent_name, chain_id) 

504 

505 

506def should_resume( 

507 agent_name: str, 

508 current_task: str, 

509 previous_task: Optional[str] = None, 

510) -> bool: 

511 """ 

512 Convenience function to check if should resume. 

513 

514 Args: 

515 agent_name: Name of the agent 

516 current_task: Description of current task 

517 previous_task: Description of previous task 

518 

519 Returns: 

520 True if should resume 

521 """ 

522 manager = get_session_manager() 

523 return manager.should_resume(agent_name, current_task, previous_task) 

524 

525 

526# Example usage for documentation 

527if __name__ == "__main__": 

528 """ 

529 Example usage of SessionManager. 

530 

531 This demonstrates the official Claude Code sub-agent patterns. 

532 """ 

533 

534 # Initialize manager 

535 manager = SessionManager() 

536 

537 # Example 1: Linear Chain (spec-builder → implementation-planner) 

538 print("=== Example 1: Linear Chain ===") 

539 

540 # Create workflow chain 

541 manager.create_chain( 

542 chain_id="SPEC-AUTH-001-planning", 

543 agent_sequence=["spec-builder", "implementation-planner"], 

544 metadata={"spec_id": "SPEC-AUTH-001", "feature": "User Authentication"}, 

545 ) 

546 

547 # Simulate spec-builder execution 

548 spec_result = { 

549 "spec_id": "SPEC-AUTH-001", 

550 "files_created": [".moai/specs/SPEC-AUTH-001/spec.md"], 

551 "status": "success", 

552 } 

553 

554 manager.register_agent_result( 

555 agent_name="spec-builder", 

556 agent_id="spec-abc123", 

557 result=spec_result, 

558 chain_id="SPEC-AUTH-001-planning", 

559 ) 

560 

561 # Simulate implementation-planner execution 

562 plan_result = { 

563 "dependencies": {"fastapi": ">=0.118.3"}, 

564 "status": "success", 

565 } 

566 

567 manager.register_agent_result( 

568 agent_name="implementation-planner", 

569 agent_id="plan-def456", 

570 result=plan_result, 

571 chain_id="SPEC-AUTH-001-planning", 

572 ) 

573 

574 # Get chain summary 

575 summary = manager.get_chain_summary("SPEC-AUTH-001-planning") 

576 print(f"Chain summary: {json.dumps(summary, indent=2)}") 

577 

578 # Example 2: Resume Pattern (tdd-implementer continues work) 

579 print("\n=== Example 2: Resume Pattern ===") 

580 

581 manager.create_chain( 

582 chain_id="SPEC-AUTH-001-implementation", 

583 agent_sequence=["tdd-implementer"], 

584 ) 

585 

586 # First execution: Implementation phase 1 

587 implementation_001_result = { 

588 "phase": "phase_1", 

589 "tests_created": ["tests/test_registration.py"], 

590 "code_created": ["src/auth/registration.py"], 

591 "status": "success", 

592 } 

593 

594 manager.register_agent_result( 

595 agent_name="tdd-implementer", 

596 agent_id="tdd-ghi789", 

597 result=implementation_001_result, 

598 chain_id="SPEC-AUTH-001-implementation", 

599 ) 

600 

601 # Get resume ID for continuing work 

602 resume_id = manager.get_resume_id( 

603 agent_name="tdd-implementer", 

604 chain_id="SPEC-AUTH-001-implementation", 

605 ) 

606 

607 print(f"Resume ID for tdd-implementer: {resume_id}") 

608 

609 # Should resume? (continuing user auth flow) 

610 should_resume_decision = manager.should_resume( 

611 agent_name="tdd-implementer", 

612 current_task="Implement user login endpoint", 

613 previous_task="Implement user registration endpoint", 

614 ) 

615 

616 print(f"Should resume? {should_resume_decision}") 

617 

618 if should_resume_decision and resume_id: 

619 print(f"✅ Resume with agentId: {resume_id}") 

620 manager.increment_resume_count(resume_id) 

621 else: 

622 print("❌ Start new session") 

623 

624 # Example 3: Parallel Analysis 

625 print("\n=== Example 3: Parallel Analysis ===") 

626 

627 manager.create_chain( 

628 chain_id="SPEC-AUTH-001-review", 

629 agent_sequence=["backend-expert", "security-expert", "frontend-expert"], 

630 metadata={"review_type": "expert_consultation"}, 

631 ) 

632 

633 # All experts run independently (no resume) 

634 experts_results = { 

635 "backend-expert": { 

636 "recommendations": ["Use JWT for auth"], 

637 "agent_id": "backend-jkl012", 

638 }, 

639 "security-expert": { 

640 "vulnerabilities": ["Rate limiting needed"], 

641 "agent_id": "security-mno345", 

642 }, 

643 "frontend-expert": { 

644 "ui_concerns": ["Token refresh flow"], 

645 "agent_id": "frontend-pqr678", 

646 }, 

647 } 

648 

649 for expert_name, data in experts_results.items(): 

650 manager.register_agent_result( 

651 agent_name=expert_name, 

652 agent_id=data["agent_id"], 

653 result={k: v for k, v in data.items() if k != "agent_id"}, 

654 chain_id="SPEC-AUTH-001-review", 

655 ) 

656 

657 # Get all review results 

658 review_results = manager.get_chain_results("SPEC-AUTH-001-review") 

659 print(f"Expert reviews: {len(review_results)} experts") 

660 

661 for result in review_results: 

662 print(f" - {result['agent_name']}: {list(result['result'].keys())}") 

663 

664 # Get all sessions 

665 print("\n=== All Sessions ===") 

666 all_sessions = manager.get_all_sessions() 

667 print(json.dumps(all_sessions, indent=2))