Coverage for agentos/background/supervisor.py: 35%

284 statements  

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

1""" # noqa: E501 

2Agent Supervision Tree — v1.11.0 

3 

4Resource-bounded agent hierarchy with monitoring, quotas, and auto-recovery. 

5Inspired by OS process supervision (systemd, supervisord). 

6 

7Features: 

8- Hierarchical supervision tree: parent monitors children 

9- Resource quotas per agent (time, cost, tokens, concurrency) 

10- Heartbeat-based health monitoring 

11- Auto-kill for runaway agents exceeding quotas 

12- Graceful degradation: kill child, preserve parent 

13- Aggregate progress across the tree 

14- Supervision events for external monitoring 

15 

16Usage: 

17 sup = AgentSupervisor() 

18 child = await sup.spawn( 

19 name="data_analyzer", 

20 loop_factory=lambda: AgentLoop(...), 

21 quotas=AgentQuota(max_duration=600, max_cost_usd=2.0), 

22 ) 

23 result = await sup.await_child(child.id, timeout=600) 

24""" 

25 

26from __future__ import annotations 

27 

28import asyncio 

29import time 

30import uuid 

31from collections.abc import Callable, Coroutine 

32from dataclasses import dataclass, field 

33from enum import StrEnum 

34from typing import Any 

35 

36# ── Enums ──────────────────────────────────────────────────────── 

37 

38 

39class SupervisionEventType(StrEnum): 

40 """Types of supervision events.""" 

41 

42 SPAWNED = "spawned" 

43 STARTED = "started" 

44 HEARTBEAT = "heartbeat" 

45 PROGRESS = "progress" 

46 QUOTA_WARNING = "quota_warning" # Nearing quota limit 

47 QUOTA_EXCEEDED = "quota_exceeded" # Quota hit, killed 

48 HEARTBEAT_LOST = "heartbeat_lost" # Child unresponsive 

49 COMPLETED = "completed" 

50 FAILED = "failed" 

51 CANCELLED = "cancelled" 

52 KILLED = "killed" # Killed by supervisor 

53 

54 

55@dataclass 

56class SupervisionEvent: 

57 """Event emitted by the supervision tree.""" 

58 

59 type: SupervisionEventType 

60 child_id: str 

61 child_name: str 

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

63 data: dict[str, Any] = field(default_factory=dict) 

64 message: str = "" 

65 

66 def to_dict(self) -> dict: 

67 return { 

68 "type": self.type.value, 

69 "child_id": self.child_id, 

70 "child_name": self.child_name, 

71 "timestamp": self.timestamp, 

72 "data": self.data, 

73 "message": self.message, 

74 } 

75 

76 

77# ── Data Models ────────────────────────────────────────────────── 

78 

79 

80@dataclass 

81class AgentQuota: 

82 """Resource limits for a supervised agent.""" 

83 

84 max_duration_seconds: float = 3600.0 # Wall-clock time budget 

85 max_cost_usd: float = 10.0 # Cost budget 

86 max_tokens: int = 1_000_000 # Token budget 

87 max_iterations: int = 500 # Max loop iterations 

88 heartbeat_interval: float = 10.0 # Seconds between heartbeats 

89 heartbeat_timeout: float = 30.0 # Seconds before considered dead 

90 max_retries: int = 0 # Auto-restart on failure (0=no restart) 

91 retry_delay: float = 5.0 # Delay before restart 

92 cooldown_period: float = 60.0 # Rate limit on restarts 

93 

94 

95@dataclass 

96class AgentQuotaUsage: 

97 """Current resource consumption of a supervised agent.""" 

98 

99 elapsed_seconds: float = 0.0 

100 cost_usd: float = 0.0 

101 tokens_used: int = 0 

102 iterations: int = 0 

103 heartbeats_received: int = 0 

104 last_heartbeat: float = 0.0 

105 restarts: int = 0 

106 last_restart: float = 0.0 

107 

108 @property 

109 def duration_percent(self) -> float: 

110 return 0.0 # Set externally with quota context 

111 

112 @property 

113 def cost_percent(self) -> float: 

114 return 0.0 

115 

116 def to_dict(self) -> dict: 

117 return { 

118 "elapsed_seconds": self.elapsed_seconds, 

119 "cost_usd": self.cost_usd, 

120 "tokens_used": self.tokens_used, 

121 "iterations": self.iterations, 

122 "heartbeats_received": self.heartbeats_received, 

123 "last_heartbeat": self.last_heartbeat, 

124 "restarts": self.restarts, 

125 } 

126 

127 

128@dataclass 

129class SupervisedAgent: 

130 """An agent running under supervision.""" 

131 

132 id: str = field(default_factory=lambda: uuid.uuid4().hex[:12]) 

133 name: str = "" 

134 quotas: AgentQuota = field(default_factory=AgentQuota) 

135 usage: AgentQuotaUsage = field(default_factory=AgentQuotaUsage) 

136 status: str = "pending" # pending/running/paused/completed/failed/killed 

137 started_at: float = 0.0 

138 finished_at: float = 0.0 

139 result: Any = None 

140 error: str = "" 

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

142 

143 # Internal 

144 _task: asyncio.Task | None = field(default=None, repr=False) 

145 _heartbeat_task: asyncio.Task | None = field(default=None, repr=False) 

146 _pause_event: asyncio.Event | None = field(default=None, repr=False) 

147 _kill_event: asyncio.Event | None = field(default=None, repr=False) 

148 

149 @property 

150 def is_alive(self) -> bool: 

151 return self.status in ("running", "paused") 

152 

153 @property 

154 def duration_seconds(self) -> float: 

155 end = self.finished_at or time.time() 

156 return end - self.started_at if self.started_at else 0.0 

157 

158 def to_dict(self) -> dict: 

159 return { 

160 "id": self.id, 

161 "name": self.name, 

162 "quotas": { 

163 "max_duration_seconds": self.quotas.max_duration_seconds, 

164 "max_cost_usd": self.quotas.max_cost_usd, 

165 "max_tokens": self.quotas.max_tokens, 

166 "max_iterations": self.quotas.max_iterations, 

167 }, 

168 "usage": self.usage.to_dict(), 

169 "status": self.status, 

170 "started_at": self.started_at, 

171 "finished_at": self.finished_at, 

172 "error": self.error, 

173 "metadata": self.metadata, 

174 } 

175 

176 

177@dataclass 

178class SupervisorConfig: 

179 """Global supervisor configuration.""" 

180 

181 max_children: int = 20 

182 monitor_interval: float = 1.0 # Seconds between health checks 

183 event_history_size: int = 500 # Max events to retain 

184 auto_kill_on_quota: bool = True 

185 log_events: bool = True 

186 

187 

188# ── Callback types ─────────────────────────────────────────────── 

189 

190EventCallback = Callable[[SupervisionEvent], None] 

191 

192 

193# ── Agent Supervisor ───────────────────────────────────────────── 

194 

195 

196class AgentSupervisor: 

197 """ 

198 Hierarchical supervision tree for long-running multi-agent tasks. 

199 

200 Monitors children for: 

201 - Resource quota violations (time, cost, tokens) 

202 - Heartbeat loss (crash/hang detection) 

203 - Progress stalls 

204 

205 Actions: 

206 - Auto-kill runaway agents 

207 - Graceful restart (optional) 

208 - Event emission for external monitoring 

209 """ 

210 

211 def __init__( 

212 self, 

213 config: SupervisorConfig | None = None, 

214 on_event: EventCallback | None = None, 

215 ): 

216 self.config = config or SupervisorConfig() 

217 self._on_event = on_event 

218 self._children: dict[str, SupervisedAgent] = {} 

219 self._events: list[SupervisionEvent] = [] 

220 self._monitor_task: asyncio.Task | None = None 

221 self._lock = asyncio.Lock() 

222 

223 # ── Public API ─────────────────────────────────────────────── 

224 

225 async def spawn( 

226 self, 

227 name: str, 

228 task: str = "", 

229 loop_factory: Callable[[], Any] | None = None, 

230 agent_loop: Any = None, 

231 quotas: AgentQuota | None = None, 

232 on_heartbeat: Callable[[], Coroutine] | None = None, 

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

234 ) -> str: 

235 """ 

236 Spawn a new child agent under supervision. 

237 

238 Returns child_id for monitoring/control. 

239 """ 

240 async with self._lock: 

241 if len(self._children) >= self.config.max_children: 

242 raise RuntimeError(f"Max children ({self.config.max_children}) reached") 

243 

244 child = SupervisedAgent( 

245 name=name, 

246 quotas=quotas or AgentQuota(), 

247 metadata=metadata or {}, 

248 ) 

249 child._pause_event = asyncio.Event() 

250 child._pause_event.set() # Not paused 

251 child._kill_event = asyncio.Event() 

252 

253 self._children[child.id] = child 

254 self._emit(SupervisionEvent(SupervisionEventType.SPAWNED, child.id, name)) 

255 

256 # Start monitoring in background 

257 if not self._monitor_task or self._monitor_task.done(): 

258 self._monitor_task = asyncio.create_task(self._monitor_loop()) 

259 

260 # Start heartbeat task 

261 if on_heartbeat: 

262 child._heartbeat_task = asyncio.create_task(self._heartbeat_loop(child, on_heartbeat)) 

263 

264 # Start execution 

265 child._task = asyncio.create_task(self._run_child(child, task, loop_factory, agent_loop)) 

266 

267 return child.id 

268 

269 async def get_child(self, child_id: str) -> SupervisedAgent | None: 

270 """Get child agent by ID.""" 

271 return self._children.get(child_id) 

272 

273 async def list_children(self) -> list[SupervisedAgent]: 

274 """List all children with their status.""" 

275 return list(self._children.values()) 

276 

277 async def await_child(self, child_id: str, timeout: float | None = None) -> Any: 

278 """Wait for a child to complete, return its result.""" 

279 child = self._children.get(child_id) 

280 if not child: 

281 raise KeyError(f"Child {child_id} not found") 

282 if not child._task: 

283 raise RuntimeError(f"Child {child_id} has no running task") 

284 

285 try: 

286 return await asyncio.wait_for(child._task, timeout=timeout) 

287 except TimeoutError: 

288 # Kill the child on timeout 

289 await self.kill_child(child_id, reason="await timeout") 

290 raise 

291 

292 async def pause_child(self, child_id: str) -> bool: 

293 """Pause a running child.""" 

294 child = self._children.get(child_id) 

295 if not child or not child.is_alive or not child._pause_event: 

296 return False 

297 child._pause_event.clear() 

298 child.status = "paused" 

299 return True 

300 

301 async def resume_child(self, child_id: str) -> bool: 

302 """Resume a paused child.""" 

303 child = self._children.get(child_id) 

304 if not child or child.status != "paused" or not child._pause_event: 

305 return False 

306 child._pause_event.set() 

307 child.status = "running" 

308 self._emit( 

309 SupervisionEvent(SupervisionEventType.PROGRESS, child.id, child.name, message="Resumed") 

310 ) 

311 return True 

312 

313 async def kill_child(self, child_id: str, reason: str = "") -> bool: 

314 """Force-kill a child agent.""" 

315 child = self._children.get(child_id) 

316 if not child or not child.is_alive: 

317 return False 

318 

319 if child._kill_event: 

320 child._kill_event.set() 

321 if child._task and not child._task.done(): 

322 child._task.cancel() 

323 

324 child.status = "killed" 

325 child.finished_at = time.time() 

326 child.error = reason 

327 

328 self._emit( 

329 SupervisionEvent( 

330 SupervisionEventType.KILLED, 

331 child.id, 

332 child.name, 

333 message=reason, 

334 ) 

335 ) 

336 return True 

337 

338 async def aggregate_progress(self) -> dict[str, Any]: 

339 """Aggregate progress across all children.""" 

340 children = list(self._children.values()) 

341 total = len(children) 

342 completed = sum(1 for c in children if c.status == "completed") 

343 failed = sum(1 for c in children if c.status in ("failed", "killed")) 

344 running = sum(1 for c in children if c.status == "running") 

345 

346 # Aggregate costs 

347 total_cost = sum(c.usage.cost_usd for c in children) 

348 total_tokens = sum(c.usage.tokens_used for c in children) 

349 

350 return { 

351 "total_children": total, 

352 "completed": completed, 

353 "failed": failed, 

354 "running": running, 

355 "total_cost_usd": total_cost, 

356 "total_tokens": total_tokens, 

357 "percent_complete": (completed / total * 100) if total > 0 else 0, 

358 "children": [c.to_dict() for c in children], 

359 } 

360 

361 async def shutdown(self, timeout: float = 10.0): 

362 """Graceful shutdown: pause new spawns, wait for children, kill stragglers.""" 

363 # Kill all running children 

364 for child_id in list(self._children.keys()): 

365 await self.kill_child(child_id, reason="supervisor shutdown") 

366 

367 if self._monitor_task and not self._monitor_task.done(): 

368 self._monitor_task.cancel() 

369 

370 # Wait for children to die 

371 deadline = time.time() + timeout 

372 for child in self._children.values(): 

373 if child._task and not child._task.done(): 

374 remaining = max(0, deadline - time.time()) 

375 try: 

376 await asyncio.wait_for(child._task, timeout=remaining) 

377 except (TimeoutError, asyncio.CancelledError): 

378 pass 

379 

380 # ── Internal ───────────────────────────────────────────────── 

381 

382 async def _run_child( 

383 self, 

384 child: SupervisedAgent, 

385 task: str, 

386 loop_factory: Callable[[], Any] | None, 

387 agent_loop: Any, 

388 ): 

389 """Execute a child agent with full supervision.""" 

390 child.status = "running" 

391 child.started_at = time.time() 

392 self._emit(SupervisionEvent(SupervisionEventType.STARTED, child.id, child.name)) 

393 

394 try: 

395 if loop_factory: 

396 loop = loop_factory() 

397 elif agent_loop: 

398 loop = agent_loop 

399 else: 

400 raise ValueError("Must provide loop_factory or agent_loop") 

401 

402 # Wrap loop to check for pause/kill signals 

403 original_on_iteration = getattr(loop, "on_iteration", None) 

404 

405 async def supervised_on_iteration(iteration: int, tool_results: list): 

406 # Check kill signal 

407 if child._kill_event and child._kill_event.is_set(): 

408 raise asyncio.CancelledError("Killed by supervisor") 

409 

410 # Check pause signal 

411 if child._pause_event: 

412 await child._pause_event.wait() 

413 

414 # Update usage 

415 child.usage.iterations = iteration 

416 child.usage.elapsed_seconds = time.time() - child.started_at 

417 

418 # Quota checks 

419 if child.usage.elapsed_seconds > child.quotas.max_duration_seconds: 

420 if self.config.auto_kill_on_quota: 

421 child._kill_event.set() 

422 raise TimeoutError("Duration quota exceeded") 

423 else: 

424 self._emit( 

425 SupervisionEvent( 

426 SupervisionEventType.QUOTA_WARNING, 

427 child.id, 

428 child.name, 

429 message=f"Duration at {child.usage.elapsed_seconds:.0f}s / {child.quotas.max_duration_seconds}s", # noqa: E501 

430 ) 

431 ) 

432 

433 if original_on_iteration: 

434 original_on_iteration(iteration, tool_results) 

435 

436 loop.on_iteration = supervised_on_iteration 

437 

438 # Run with retry logic 

439 for attempt in range(child.quotas.max_retries + 1): 

440 try: 

441 result = await loop.run(task, session_id=child.id) 

442 child.result = result.output if hasattr(result, "output") else result 

443 child.usage.cost_usd = getattr(result, "cost_usd", 0.0) 

444 child.usage.tokens_used = sum(getattr(result, "tokens_used", {}).values()) 

445 child.status = "completed" 

446 child.finished_at = time.time() 

447 self._emit( 

448 SupervisionEvent(SupervisionEventType.COMPLETED, child.id, child.name) 

449 ) 

450 return child.result 

451 except (TimeoutError, asyncio.CancelledError): 

452 raise 

453 except Exception as e: 

454 if attempt < child.quotas.max_retries: 

455 # Check cooldown 

456 since_last = time.time() - child.usage.last_restart 

457 if child.usage.restarts > 0 and since_last < child.quotas.cooldown_period: 

458 await asyncio.sleep(child.quotas.cooldown_period - since_last) 

459 child.usage.restarts += 1 

460 child.usage.last_restart = time.time() 

461 await asyncio.sleep(child.quotas.retry_delay) 

462 continue 

463 child.status = "failed" 

464 child.finished_at = time.time() 

465 child.error = str(e) 

466 self._emit( 

467 SupervisionEvent( 

468 SupervisionEventType.FAILED, 

469 child.id, 

470 child.name, 

471 message=str(e), 

472 ) 

473 ) 

474 raise 

475 

476 except asyncio.CancelledError: 

477 child.status = "killed" 

478 child.finished_at = time.time() 

479 except Exception as e: 

480 child.status = "failed" 

481 child.finished_at = time.time() 

482 child.error = str(e) 

483 self._emit( 

484 SupervisionEvent( 

485 SupervisionEventType.FAILED, 

486 child.id, 

487 child.name, 

488 message=str(e), 

489 ) 

490 ) 

491 

492 async def _heartbeat_loop( 

493 self, 

494 child: SupervisedAgent, 

495 on_heartbeat: Callable[[], Coroutine], 

496 ): 

497 """Send periodic heartbeats and update usage.""" 

498 interval = child.quotas.heartbeat_interval 

499 while child.is_alive: 

500 try: 

501 await asyncio.sleep(interval) 

502 if not child.is_alive: 

503 break 

504 await on_heartbeat() 

505 child.usage.heartbeats_received += 1 

506 child.usage.last_heartbeat = time.time() 

507 self._emit( 

508 SupervisionEvent( 

509 SupervisionEventType.HEARTBEAT, 

510 child.id, 

511 child.name, 

512 data={"heartbeats": child.usage.heartbeats_received}, 

513 ) 

514 ) 

515 except asyncio.CancelledError: 

516 break 

517 except Exception: 

518 pass 

519 

520 async def _monitor_loop(self): 

521 """Monitor all children for health and quota violations.""" 

522 while True: 

523 try: 

524 await asyncio.sleep(self.config.monitor_interval) 

525 now = time.time() 

526 

527 for child in list(self._children.values()): 

528 if not child.is_alive: 

529 continue 

530 

531 # Heartbeat timeout check 

532 if ( 

533 child.quotas.heartbeat_timeout > 0 

534 and child.usage.last_heartbeat > 0 

535 and now - child.usage.last_heartbeat > child.quotas.heartbeat_timeout 

536 ): 

537 self._emit( 

538 SupervisionEvent( 

539 SupervisionEventType.HEARTBEAT_LOST, 

540 child.id, 

541 child.name, 

542 message=f"No heartbeat for {now - child.usage.last_heartbeat:.0f}s", 

543 ) 

544 ) 

545 await self.kill_child(child.id, reason="heartbeat lost") 

546 

547 # Duration check 

548 elapsed = now - child.started_at if child.started_at else 0 

549 if elapsed > child.quotas.max_duration_seconds * 0.9: 

550 self._emit( 

551 SupervisionEvent( 

552 SupervisionEventType.QUOTA_WARNING, 

553 child.id, 

554 child.name, 

555 message=f"90% duration used: {elapsed:.0f}s / {child.quotas.max_duration_seconds}s", 

556 ) 

557 ) 

558 

559 except asyncio.CancelledError: 

560 break 

561 except Exception: 

562 pass 

563 

564 def _emit(self, event: SupervisionEvent): 

565 """Emit a supervision event.""" 

566 if self.config.log_events: 

567 self._events.append(event) 

568 if len(self._events) > self.config.event_history_size: 

569 self._events = self._events[-self.config.event_history_size :] 

570 

571 if self._on_event: 

572 try: 

573 self._on_event(event) 

574 except Exception: 

575 pass