Coverage for agentos/swarm/agent_monitor.py: 28%

218 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 12:29 +0800

1""" 

2v1.9.6: Agent Self-Monitoring & Quality Gates. 

3 

4Each agent execution passes through configurable quality checks before 

5results are accepted. Failed checks trigger automatic fallback or retry. 

6""" 

7 

8from __future__ import annotations 

9 

10import time 

11import uuid 

12from collections.abc import Callable 

13from dataclasses import dataclass, field 

14from enum import StrEnum 

15from typing import Any 

16 

17 

18class GateStatus(StrEnum): 

19 PASS = "pass" 

20 FAIL = "fail" 

21 WARN = "warn" 

22 SKIP = "skip" 

23 

24 

25class GateAction(StrEnum): 

26 ACCEPT = "accept" # Accept result as-is 

27 RETRY = "retry" # Retry the task 

28 FALLBACK = "fallback" # Use fallback result 

29 ABORT = "abort" # Abort the task 

30 WARN = "warn" # Accept but flag warning 

31 

32 

33@dataclass 

34class GateResult: 

35 """Result of a single quality gate check.""" 

36 

37 name: str 

38 status: GateStatus = GateStatus.PASS 

39 action: GateAction = GateAction.ACCEPT 

40 score: float = 1.0 

41 threshold: float = 0.7 

42 detail: str = "" 

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

44 

45 def to_dict(self) -> dict: 

46 return { 

47 "name": self.name, 

48 "status": self.status.value, 

49 "action": self.action.value, 

50 "score": self.score, 

51 "threshold": self.threshold, 

52 "detail": self.detail, 

53 } 

54 

55 

56@dataclass 

57class MonitorReport: 

58 """Complete self-monitoring report for a task execution.""" 

59 

60 task_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) 

61 task_name: str = "" 

62 gates: list[GateResult] = field(default_factory=list) 

63 overall_status: GateStatus = GateStatus.PASS 

64 overall_action: GateAction = GateAction.ACCEPT 

65 total_checks: int = 0 

66 passed: int = 0 

67 failed: int = 0 

68 warned: int = 0 

69 duration_ms: float = 0.0 

70 retries_used: int = 0 

71 max_retries: int = 3 

72 fallback_used: bool = False 

73 output_summary: str = "" 

74 

75 def to_dict(self) -> dict: 

76 return { 

77 "task_id": self.task_id, 

78 "task_name": self.task_name, 

79 "gates": [g.to_dict() for g in self.gates], 

80 "overall_status": self.overall_status.value, 

81 "overall_action": self.overall_action.value, 

82 "total_checks": self.total_checks, 

83 "passed": self.passed, 

84 "failed": self.failed, 

85 "warned": self.warned, 

86 "duration_ms": f"{self.duration_ms:.1f}", 

87 "retries_used": self.retries_used, 

88 "fallback_used": self.fallback_used, 

89 "output_summary": self.output_summary[:200], 

90 } 

91 

92 

93class QualityGate: 

94 """A single quality check that validates agent output. 

95 

96 Built-in gate types: output_not_empty, output_length, confidence_min, 

97 schema_valid, no_error, latency_max. 

98 """ 

99 

100 def __init__( 

101 self, 

102 name: str, 

103 check_fn: Callable[[Any, dict], tuple[bool, str, float]], 

104 threshold: float = 0.7, 

105 on_fail: GateAction = GateAction.RETRY, 

106 on_warn: GateAction = GateAction.ACCEPT, 

107 max_retries: int = 1, 

108 ): 

109 """ 

110 Args: 

111 name: Gate name for reporting 

112 check_fn: (output, context) → (passed, detail, score) 

113 threshold: Score threshold for pass (0-1) 

114 on_fail: Action when gate fails 

115 on_warn: Action when gate warns 

116 max_retries: Max retries for this specific gate 

117 """ 

118 self.name = name 

119 self._check = check_fn 

120 self.threshold = threshold 

121 self.on_fail = on_fail 

122 self.on_warn = on_warn 

123 self.max_retries = max_retries 

124 

125 def evaluate(self, output: Any, context: dict | None = None) -> GateResult: 

126 """Run the quality check.""" 

127 ctx = context or {} 

128 try: 

129 passed, detail, score = self._check(output, ctx) 

130 except Exception as e: 

131 return GateResult( 

132 name=self.name, 

133 status=GateStatus.FAIL, 

134 action=self.on_fail, 

135 score=0.0, 

136 threshold=self.threshold, 

137 detail=f"Gate check error: {e}", 

138 ) 

139 

140 if score >= self.threshold and passed: 

141 return GateResult( 

142 name=self.name, 

143 status=GateStatus.PASS, 

144 action=GateAction.ACCEPT, 

145 score=score, 

146 threshold=self.threshold, 

147 detail=detail, 

148 ) 

149 elif score >= self.threshold * 0.6: 

150 return GateResult( 

151 name=self.name, 

152 status=GateStatus.WARN, 

153 action=self.on_warn, 

154 score=score, 

155 threshold=self.threshold, 

156 detail=detail, 

157 ) 

158 else: 

159 return GateResult( 

160 name=self.name, 

161 status=GateStatus.FAIL, 

162 action=self.on_fail, 

163 score=score, 

164 threshold=self.threshold, 

165 detail=detail, 

166 ) 

167 

168 

169class AgentMonitor: 

170 """ 

171 Self-monitoring pipeline for agent task execution. 

172 

173 Runs each task output through a chain of quality gates. Based on gate results, 

174 decides whether to accept, retry, fallback, or abort. 

175 

176 Usage: 

177 monitor = AgentMonitor() 

178 monitor.add_gate(output_not_empty_gate) 

179 monitor.add_gate(confidence_min_gate) 

180 

181 result = await monitor.monitor_execution( 

182 task_fn=lambda: agent.run(task), 

183 task_name="research_query", 

184 ) 

185 if result.overall_action == GateAction.ACCEPT: 

186 ... 

187 """ 

188 

189 def __init__(self, max_retries: int = 3, default_fallback: Any = None): 

190 self._gates: list[QualityGate] = [] 

191 self.max_retries = max_retries 

192 self.default_fallback = default_fallback 

193 

194 def add_gate(self, gate: QualityGate) -> AgentMonitor: 

195 """Add a quality gate to the pipeline.""" 

196 self._gates.append(gate) 

197 return self 

198 

199 def add_gates(self, gates: list[QualityGate]) -> AgentMonitor: 

200 """Add multiple quality gates.""" 

201 self._gates.extend(gates) 

202 return self 

203 

204 async def monitor_execution( 

205 self, 

206 task_fn: Callable[[], Any], 

207 task_name: str = "", 

208 context: dict | None = None, 

209 fallback_fn: Callable[[], Any] | None = None, 

210 ) -> tuple[Any, MonitorReport]: 

211 """Execute a task with full monitoring and quality gating. 

212 

213 Args: 

214 task_fn: Async/sync function that executes the task 

215 context: Additional context for gate evaluation 

216 fallback_fn: Fallback function to call if gates fail with FALLBACK action 

217 

218 Returns: 

219 Tuple of (final_output, monitor_report) 

220 """ 

221 import asyncio 

222 

223 report = MonitorReport(task_name=task_name) 

224 ctx = context or {} 

225 start = time.time() 

226 

227 output = None 

228 retries = 0 

229 

230 while retries <= self.max_retries: 

231 # Execute task 

232 try: 

233 result = task_fn() 

234 if asyncio.iscoroutine(result): 

235 output = await result 

236 else: 

237 output = result 

238 except Exception as e: 

239 report.gates.append( 

240 GateResult( 

241 name="execution_error", 

242 status=GateStatus.FAIL, 

243 action=GateAction.RETRY, 

244 score=0.0, 

245 detail=str(e), 

246 ) 

247 ) 

248 retries += 1 

249 if retries > self.max_retries: 

250 report.overall_status = GateStatus.FAIL 

251 report.overall_action = GateAction.FALLBACK 

252 break 

253 continue 

254 

255 # Run gates 

256 report.gates = [] 

257 any_fail = False 

258 worst_action = GateAction.ACCEPT 

259 action_prio = { 

260 GateAction.ACCEPT: 0, 

261 GateAction.WARN: 1, 

262 GateAction.RETRY: 2, 

263 GateAction.FALLBACK: 3, 

264 GateAction.ABORT: 4, 

265 } 

266 

267 for gate in self._gates: 

268 gr = gate.evaluate(output, ctx) 

269 report.gates.append(gr) 

270 

271 if gr.status == GateStatus.FAIL: 

272 any_fail = True 

273 if action_prio.get(gr.action, 0) > action_prio.get(worst_action, 0): 

274 worst_action = gr.action 

275 

276 # Tally 

277 report.total_checks = len(report.gates) 

278 report.passed = sum(1 for g in report.gates if g.status == GateStatus.PASS) 

279 report.failed = sum(1 for g in report.gates if g.status == GateStatus.FAIL) 

280 report.warned = sum(1 for g in report.gates if g.status == GateStatus.WARN) 

281 

282 if not any_fail: 

283 report.overall_status = GateStatus.PASS 

284 report.overall_action = worst_action 

285 report.retries_used = retries 

286 report.output_summary = self._summarize(output) 

287 report.duration_ms = (time.time() - start) * 1000 

288 return output, report 

289 

290 # Handle failure 

291 if worst_action == GateAction.ABORT: 

292 report.overall_status = GateStatus.FAIL 

293 report.overall_action = GateAction.ABORT 

294 report.retries_used = retries 

295 report.duration_ms = (time.time() - start) * 1000 

296 return output, report 

297 

298 if worst_action == GateAction.FALLBACK: 

299 report.overall_status = GateStatus.FAIL 

300 report.overall_action = GateAction.FALLBACK 

301 report.retries_used = retries 

302 report.fallback_used = True 

303 report.duration_ms = (time.time() - start) * 1000 

304 

305 if fallback_fn: 

306 fb_result = fallback_fn() 

307 if asyncio.iscoroutine(fb_result): 

308 output = await fb_result 

309 else: 

310 output = fb_result 

311 elif self.default_fallback is not None: 

312 output = self.default_fallback 

313 

314 report.output_summary = self._summarize(output) 

315 return output, report 

316 

317 # RETRY or WARN — continue loop 

318 retries += 1 

319 

320 # Exhausted retries 

321 report.overall_status = GateStatus.FAIL if report.failed > 0 else GateStatus.WARN 

322 report.overall_action = GateAction.FALLBACK 

323 report.retries_used = retries 

324 report.duration_ms = (time.time() - start) * 1000 

325 

326 if fallback_fn: 

327 fb_result = fallback_fn() 

328 if asyncio.iscoroutine(fb_result): 

329 output = await fb_result 

330 else: 

331 output = fb_result 

332 elif self.default_fallback is not None: 

333 output = self.default_fallback 

334 

335 report.output_summary = self._summarize(output) 

336 return output, report 

337 

338 def _summarize(self, output: Any) -> str: 

339 """Create a brief summary of output for reporting.""" 

340 if output is None: 

341 return "None" 

342 s = str(output) 

343 if len(s) > 200: 

344 return s[:197] + "..." 

345 return s 

346 

347 

348# ── Built-in Quality Gates ──────────────────────────────────────── 

349 

350 

351def output_not_empty( 

352 min_length: int = 1, 

353 threshold: float = 0.9, 

354) -> QualityGate: 

355 """Gate: output must not be empty.""" 

356 

357 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

358 s = str(output).strip() if output else "" 

359 score = min(1.0, len(s) / max(min_length, 1)) 

360 if not s: 

361 return False, "Output is empty", 0.0 

362 if len(s) < min_length: 

363 return False, f"Output too short ({len(s)} < {min_length})", score 

364 return True, f"Output length {len(s)} OK", 1.0 

365 

366 return QualityGate("output_not_empty", check, threshold, on_fail=GateAction.RETRY) 

367 

368 

369def output_length_range( 

370 min_len: int = 10, 

371 max_len: int = 10000, 

372 threshold: float = 0.8, 

373) -> QualityGate: 

374 """Gate: output length must be in range.""" 

375 

376 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

377 s = str(output).strip() if output else "" 

378 length = len(s) 

379 if length < min_len: 

380 return False, f"Output too short: {length} < {min_len}", length / max(min_len, 1) 

381 if length > max_len: 

382 return False, f"Output too long: {length} > {max_len}", max_len / length 

383 return True, f"Output length {length} OK", 1.0 

384 

385 return QualityGate("output_length", check, threshold, on_fail=GateAction.WARN) 

386 

387 

388def no_error_output(threshold: float = 0.95) -> QualityGate: 

389 """Gate: output must not contain error/exception patterns.""" 

390 ERROR_PATTERNS = [ 

391 "Traceback (most recent call last)", 

392 "Error:", 

393 "Exception:", 

394 "failed to", 

395 "cannot be", 

396 "invalid", 

397 "permission denied", 

398 ] 

399 

400 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

401 s = str(output).lower() 

402 hits = [p for p in ERROR_PATTERNS if p.lower() in s] 

403 if hits: 

404 score = 1.0 - (len(hits) / len(ERROR_PATTERNS)) 

405 return False, f"Error patterns found: {hits[:3]}", max(0, score) 

406 return True, "No error patterns", 1.0 

407 

408 return QualityGate("no_error", check, threshold, on_fail=GateAction.RETRY) 

409 

410 

411def contains_keywords( 

412 keywords: list[str], 

413 min_hits: int = 1, 

414 threshold: float = 0.7, 

415) -> QualityGate: 

416 """Gate: output must contain at least N keywords.""" 

417 

418 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

419 s = str(output).lower() 

420 hits = [kw for kw in keywords if kw.lower() in s] 

421 score = min(1.0, len(hits) / max(min_hits, 1)) 

422 if len(hits) < min_hits: 

423 missing = [kw for kw in keywords if kw.lower() not in s] 

424 return False, f"Missing keywords: {missing[:5]}", score 

425 return True, f"Found {len(hits)}/{len(keywords)} keywords", 1.0 

426 

427 return QualityGate("keywords", check, threshold, on_fail=GateAction.WARN) 

428 

429 

430def latency_max(max_ms: float, threshold: float = 0.9) -> QualityGate: 

431 """Gate: execution must complete within time limit (ms).""" 

432 

433 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

434 elapsed = ctx.get("_latency_ms", 0) 

435 score = max(0, 1.0 - (elapsed / max_ms)) 

436 if elapsed > max_ms: 

437 return False, f"Latency {elapsed:.0f}ms > {max_ms}ms", score 

438 return True, f"Latency {elapsed:.0f}ms OK", 1.0 

439 

440 return QualityGate("latency", check, threshold, on_fail=GateAction.WARN) 

441 

442 

443def confidence_min(min_confidence: float = 0.5, threshold: float = 0.8) -> QualityGate: 

444 """Gate: fused confidence must meet minimum.""" 

445 

446 def check(output: Any, ctx: dict) -> tuple[bool, str, float]: 

447 confidence = ctx.get("_confidence", 0.0) 

448 score = min(1.0, confidence / max(min_confidence, 0.01)) 

449 if confidence < min_confidence: 

450 return False, f"Confidence {confidence:.2f} < {min_confidence}", score 

451 return True, f"Confidence {confidence:.2f} OK", 1.0 

452 

453 return QualityGate("confidence", check, threshold, on_fail=GateAction.RETRY)