nat-engine 1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. mannf/__init__.py +33 -0
  2. mannf/__main__.py +10 -0
  3. mannf/_version.py +8 -0
  4. mannf/agents/__init__.py +7 -0
  5. mannf/agents/analyzer_agent.py +9 -0
  6. mannf/agents/base.py +9 -0
  7. mannf/agents/bdi_agent.py +9 -0
  8. mannf/agents/belief_state.py +9 -0
  9. mannf/agents/coordinator_agent.py +9 -0
  10. mannf/agents/executor_agent.py +9 -0
  11. mannf/agents/monitor_agent.py +9 -0
  12. mannf/agents/oracle_agent.py +9 -0
  13. mannf/agents/planner_agent.py +9 -0
  14. mannf/agents/test_agent.py +9 -0
  15. mannf/anomaly/__init__.py +7 -0
  16. mannf/anomaly/enhanced_detector.py +9 -0
  17. mannf/cli.py +9 -0
  18. mannf/core/__init__.py +26 -0
  19. mannf/core/agents/__init__.py +52 -0
  20. mannf/core/agents/accessibility_scanner_agent.py +245 -0
  21. mannf/core/agents/analyzer_agent.py +224 -0
  22. mannf/core/agents/autonomous_loop_agent.py +1086 -0
  23. mannf/core/agents/autonomous_loop_models.py +62 -0
  24. mannf/core/agents/autonomous_run_differ.py +427 -0
  25. mannf/core/agents/base.py +128 -0
  26. mannf/core/agents/bdi_agent.py +330 -0
  27. mannf/core/agents/belief_state.py +202 -0
  28. mannf/core/agents/browser_coordinator_agent.py +224 -0
  29. mannf/core/agents/browser_executor_agent.py +410 -0
  30. mannf/core/agents/coordinator_agent.py +262 -0
  31. mannf/core/agents/executor_agent.py +222 -0
  32. mannf/core/agents/monitor_agent.py +188 -0
  33. mannf/core/agents/oracle_agent.py +150 -0
  34. mannf/core/agents/performance_testing_agent.py +279 -0
  35. mannf/core/agents/planner_agent.py +128 -0
  36. mannf/core/agents/test_agent.py +249 -0
  37. mannf/core/agents/visual_regression_agent.py +311 -0
  38. mannf/core/agents/web_crawler_agent.py +510 -0
  39. mannf/core/agents/worker_pool.py +366 -0
  40. mannf/core/anomaly/__init__.py +14 -0
  41. mannf/core/anomaly/enhanced_detector.py +541 -0
  42. mannf/core/browser/__init__.py +63 -0
  43. mannf/core/browser/accessibility_scanner.py +424 -0
  44. mannf/core/browser/discovery_model.py +178 -0
  45. mannf/core/browser/dom_snapshot.py +349 -0
  46. mannf/core/browser/ingestor_bridge.py +371 -0
  47. mannf/core/browser/performance_metrics.py +217 -0
  48. mannf/core/browser/reflection_analyzer.py +442 -0
  49. mannf/core/browser/scenario_generator.py +1100 -0
  50. mannf/core/browser/security_scenario_generator.py +695 -0
  51. mannf/core/browser/visual_comparer.py +159 -0
  52. mannf/core/diagnostics/__init__.py +28 -0
  53. mannf/core/diagnostics/failure_clusterer.py +211 -0
  54. mannf/core/diagnostics/flake_detector.py +233 -0
  55. mannf/core/diagnostics/root_cause_analyzer.py +273 -0
  56. mannf/core/distributed/__init__.py +16 -0
  57. mannf/core/distributed/endpoint.py +139 -0
  58. mannf/core/distributed/system_under_test.py +207 -0
  59. mannf/core/functional_orchestrator.py +428 -0
  60. mannf/core/messaging/__init__.py +11 -0
  61. mannf/core/messaging/bus.py +113 -0
  62. mannf/core/messaging/messages.py +89 -0
  63. mannf/core/nat_orchestrator.py +342 -0
  64. mannf/core/neural/__init__.py +183 -0
  65. mannf/core/orchestrator.py +272 -0
  66. mannf/core/prioritization/__init__.py +17 -0
  67. mannf/core/prioritization/adaptive_controller.py +509 -0
  68. mannf/core/prioritization/belief_prioritizer.py +231 -0
  69. mannf/core/prioritization/risk_scorer.py +430 -0
  70. mannf/core/reporting/__init__.py +12 -0
  71. mannf/core/reporting/unified_report.py +664 -0
  72. mannf/core/testing/__init__.py +17 -0
  73. mannf/core/testing/adaptive_controller.py +149 -0
  74. mannf/core/testing/models.py +179 -0
  75. mannf/core/validation/__init__.py +10 -0
  76. mannf/core/validation/self_validation_runner.py +180 -0
  77. mannf/dashboard/__init__.py +7 -0
  78. mannf/dashboard/app.py +9 -0
  79. mannf/dashboard/models.py +9 -0
  80. mannf/dashboard/static/index.html +2538 -0
  81. mannf/dashboard/telemetry.py +9 -0
  82. mannf/distributed/__init__.py +7 -0
  83. mannf/distributed/endpoint.py +9 -0
  84. mannf/distributed/system_under_test.py +9 -0
  85. mannf/healing/__init__.py +7 -0
  86. mannf/healing/graphql_schema_diff.py +9 -0
  87. mannf/healing/healer.py +9 -0
  88. mannf/healing/models.py +9 -0
  89. mannf/healing/schema_diff.py +9 -0
  90. mannf/integrations/__init__.py +7 -0
  91. mannf/integrations/auth.py +9 -0
  92. mannf/integrations/graphql_parser.py +9 -0
  93. mannf/integrations/graphql_sut.py +9 -0
  94. mannf/integrations/http_sut.py +9 -0
  95. mannf/integrations/openapi_parser.py +9 -0
  96. mannf/integrations/postman_parser.py +9 -0
  97. mannf/llm/__init__.py +7 -0
  98. mannf/llm/anthropic_provider.py +9 -0
  99. mannf/llm/base.py +9 -0
  100. mannf/llm/config.py +9 -0
  101. mannf/llm/factory.py +9 -0
  102. mannf/llm/openai_provider.py +9 -0
  103. mannf/llm/prompts.py +9 -0
  104. mannf/messaging/__init__.py +7 -0
  105. mannf/messaging/bus.py +9 -0
  106. mannf/messaging/messages.py +9 -0
  107. mannf/nat_orchestrator.py +9 -0
  108. mannf/neural/__init__.py +7 -0
  109. mannf/orchestrator.py +9 -0
  110. mannf/prioritization/__init__.py +7 -0
  111. mannf/prioritization/adaptive_controller.py +9 -0
  112. mannf/prioritization/belief_prioritizer.py +9 -0
  113. mannf/prioritization/risk_scorer.py +9 -0
  114. mannf/product/__init__.py +29 -0
  115. mannf/product/admin/__init__.py +3 -0
  116. mannf/product/admin/routes.py +514 -0
  117. mannf/product/auth/__init__.py +5 -0
  118. mannf/product/auth/saml.py +212 -0
  119. mannf/product/billing/__init__.py +5 -0
  120. mannf/product/billing/audit.py +160 -0
  121. mannf/product/billing/feature_gates.py +180 -0
  122. mannf/product/billing/metering.py +179 -0
  123. mannf/product/billing/notifications.py +181 -0
  124. mannf/product/billing/plans.py +133 -0
  125. mannf/product/billing/rate_limits.py +35 -0
  126. mannf/product/billing/stripe_billing.py +906 -0
  127. mannf/product/billing/tenant_auth.py +233 -0
  128. mannf/product/billing/tenant_manager.py +873 -0
  129. mannf/product/cli.py +3900 -0
  130. mannf/product/cli_admin.py +408 -0
  131. mannf/product/dashboard/__init__.py +61 -0
  132. mannf/product/dashboard/app.py +3567 -0
  133. mannf/product/dashboard/models.py +460 -0
  134. mannf/product/dashboard/static/index.html +6347 -0
  135. mannf/product/dashboard/static/manifest.json +25 -0
  136. mannf/product/dashboard/static/pwa-icon-192.png +0 -0
  137. mannf/product/dashboard/static/pwa-icon-512.png +0 -0
  138. mannf/product/dashboard/static/sw.js +64 -0
  139. mannf/product/dashboard/telemetry.py +547 -0
  140. mannf/product/database.py +145 -0
  141. mannf/product/demo.py +844 -0
  142. mannf/product/doctor.py +509 -0
  143. mannf/product/exporters/__init__.py +65 -0
  144. mannf/product/exporters/azuredevops_exporter.py +257 -0
  145. mannf/product/exporters/base.py +307 -0
  146. mannf/product/exporters/bugzilla_exporter.py +200 -0
  147. mannf/product/exporters/dedup.py +275 -0
  148. mannf/product/exporters/finding_adapter.py +216 -0
  149. mannf/product/exporters/github_exporter.py +197 -0
  150. mannf/product/exporters/gitlab_exporter.py +215 -0
  151. mannf/product/exporters/jira_exporter.py +180 -0
  152. mannf/product/exporters/linear_exporter.py +195 -0
  153. mannf/product/exporters/loader.py +233 -0
  154. mannf/product/exporters/pagerduty_exporter.py +363 -0
  155. mannf/product/exporters/sentry_exporter.py +322 -0
  156. mannf/product/exporters/servicenow_exporter.py +240 -0
  157. mannf/product/exporters/shortcut_exporter.py +231 -0
  158. mannf/product/exporters/webhook_exporter.py +383 -0
  159. mannf/product/formatters/__init__.py +18 -0
  160. mannf/product/formatters/allure_formatter.py +161 -0
  161. mannf/product/formatters/ctrf_formatter.py +149 -0
  162. mannf/product/healing/__init__.py +30 -0
  163. mannf/product/healing/graphql_schema_diff.py +152 -0
  164. mannf/product/healing/healer.py +141 -0
  165. mannf/product/healing/models.py +175 -0
  166. mannf/product/healing/schema_diff.py +251 -0
  167. mannf/product/ingestors/__init__.py +77 -0
  168. mannf/product/ingestors/base.py +256 -0
  169. mannf/product/ingestors/bgstm_ingestor.py +764 -0
  170. mannf/product/ingestors/curl_ingestor.py +1019 -0
  171. mannf/product/ingestors/cypress_ingestor.py +487 -0
  172. mannf/product/ingestors/gherkin_ingestor.py +967 -0
  173. mannf/product/ingestors/graphql_ingestor.py +845 -0
  174. mannf/product/ingestors/grpc_ingestor.py +591 -0
  175. mannf/product/ingestors/har_ingestor.py +976 -0
  176. mannf/product/ingestors/loader.py +284 -0
  177. mannf/product/ingestors/models.py +146 -0
  178. mannf/product/ingestors/openapi_ingestor.py +606 -0
  179. mannf/product/ingestors/playwright_ingestor.py +449 -0
  180. mannf/product/ingestors/postman_ingestor.py +631 -0
  181. mannf/product/ingestors/traffic_ingestor.py +679 -0
  182. mannf/product/ingestors/websocket_ingestor.py +526 -0
  183. mannf/product/integrations/__init__.py +21 -0
  184. mannf/product/integrations/auth.py +190 -0
  185. mannf/product/integrations/graphql_parser.py +436 -0
  186. mannf/product/integrations/graphql_sut.py +247 -0
  187. mannf/product/integrations/grpc_sut.py +469 -0
  188. mannf/product/integrations/http_sut.py +237 -0
  189. mannf/product/integrations/kafka_adapter.py +342 -0
  190. mannf/product/integrations/openapi_parser.py +513 -0
  191. mannf/product/integrations/postman_parser.py +467 -0
  192. mannf/product/integrations/webhook_receiver.py +344 -0
  193. mannf/product/integrations/websocket_sut.py +434 -0
  194. mannf/product/llm/__init__.py +25 -0
  195. mannf/product/llm/anthropic_provider.py +94 -0
  196. mannf/product/llm/base.py +267 -0
  197. mannf/product/llm/config.py +48 -0
  198. mannf/product/llm/factory.py +42 -0
  199. mannf/product/llm/openai_provider.py +93 -0
  200. mannf/product/llm/prompts.py +403 -0
  201. mannf/product/llm/root_cause_service.py +311 -0
  202. mannf/product/llm/test_plan_models.py +78 -0
  203. mannf/product/metrics.py +149 -0
  204. mannf/product/middleware/__init__.py +3 -0
  205. mannf/product/middleware/audit_middleware.py +112 -0
  206. mannf/product/middleware/tenant_isolation.py +114 -0
  207. mannf/product/models.py +347 -0
  208. mannf/product/notifications/__init__.py +24 -0
  209. mannf/product/notifications/dispatcher.py +411 -0
  210. mannf/product/onboarding.py +190 -0
  211. mannf/product/orchestration/__init__.py +39 -0
  212. mannf/product/orchestration/ingest_scan_orchestrator.py +339 -0
  213. mannf/product/orchestration/pipeline.py +401 -0
  214. mannf/product/orchestrator.py +987 -0
  215. mannf/product/orchestrator_models.py +269 -0
  216. mannf/product/regression/__init__.py +36 -0
  217. mannf/product/regression/differ.py +172 -0
  218. mannf/product/regression/masking.py +100 -0
  219. mannf/product/regression/models.py +232 -0
  220. mannf/product/regression/recorder.py +124 -0
  221. mannf/product/regression/replayer.py +168 -0
  222. mannf/product/reports/__init__.py +10 -0
  223. mannf/product/reports/pdf.py +132 -0
  224. mannf/product/scheduling/__init__.py +57 -0
  225. mannf/product/scheduling/cron_utils.py +251 -0
  226. mannf/product/scheduling/engine.py +473 -0
  227. mannf/product/scheduling/models.py +86 -0
  228. mannf/product/scheduling/queue.py +894 -0
  229. mannf/product/scheduling/store.py +235 -0
  230. mannf/product/security/__init__.py +21 -0
  231. mannf/product/security/belief_guided.py +143 -0
  232. mannf/product/security/checks/__init__.py +55 -0
  233. mannf/product/security/checks/base.py +69 -0
  234. mannf/product/security/checks/bfla.py +77 -0
  235. mannf/product/security/checks/bola.py +77 -0
  236. mannf/product/security/checks/bopla.py +80 -0
  237. mannf/product/security/checks/broken_auth.py +86 -0
  238. mannf/product/security/checks/graphql_security.py +299 -0
  239. mannf/product/security/checks/inventory.py +70 -0
  240. mannf/product/security/checks/misconfig.py +158 -0
  241. mannf/product/security/checks/resource_consumption.py +70 -0
  242. mannf/product/security/checks/sensitive_flows.py +80 -0
  243. mannf/product/security/checks/ssrf.py +101 -0
  244. mannf/product/security/checks/unsafe_consumption.py +120 -0
  245. mannf/product/security/models.py +92 -0
  246. mannf/product/security/plugin_loader.py +182 -0
  247. mannf/product/security/reporter.py +92 -0
  248. mannf/product/security/scanner.py +183 -0
  249. mannf/product/server.py +6220 -0
  250. mannf/product/setup_wizard.py +873 -0
  251. mannf/product/status.py +404 -0
  252. mannf/product/storage/__init__.py +10 -0
  253. mannf/product/storage/artifact_store.py +343 -0
  254. mannf/product/telemetry.py +300 -0
  255. mannf/product/uninstall.py +169 -0
  256. mannf/product/upgrade.py +139 -0
  257. mannf/product/weights/__init__.py +13 -0
  258. mannf/product/weights/blob_store.py +299 -0
  259. mannf/product/weights/factory.py +42 -0
  260. mannf/product/weights/registry.py +159 -0
  261. mannf/product/weights/store.py +210 -0
  262. mannf/regression/__init__.py +7 -0
  263. mannf/regression/differ.py +9 -0
  264. mannf/regression/masking.py +9 -0
  265. mannf/regression/models.py +9 -0
  266. mannf/regression/recorder.py +9 -0
  267. mannf/regression/replayer.py +9 -0
  268. mannf/security/__init__.py +7 -0
  269. mannf/security/belief_guided.py +9 -0
  270. mannf/security/checks/__init__.py +7 -0
  271. mannf/security/checks/base.py +9 -0
  272. mannf/security/checks/bfla.py +9 -0
  273. mannf/security/checks/bola.py +9 -0
  274. mannf/security/checks/bopla.py +9 -0
  275. mannf/security/checks/broken_auth.py +9 -0
  276. mannf/security/checks/graphql_security.py +9 -0
  277. mannf/security/checks/inventory.py +9 -0
  278. mannf/security/checks/misconfig.py +9 -0
  279. mannf/security/checks/resource_consumption.py +9 -0
  280. mannf/security/checks/sensitive_flows.py +9 -0
  281. mannf/security/checks/ssrf.py +9 -0
  282. mannf/security/checks/unsafe_consumption.py +9 -0
  283. mannf/security/models.py +9 -0
  284. mannf/security/reporter.py +9 -0
  285. mannf/security/scanner.py +9 -0
  286. mannf/server.py +9 -0
  287. mannf/testing/__init__.py +7 -0
  288. mannf/testing/adaptive_controller.py +9 -0
  289. mannf/testing/models.py +9 -0
  290. mannf/weights/__init__.py +7 -0
  291. mannf/weights/registry.py +9 -0
  292. mannf/weights/store.py +9 -0
  293. nat_engine-1.dist-info/METADATA +555 -0
  294. nat_engine-1.dist-info/RECORD +299 -0
  295. nat_engine-1.dist-info/WHEEL +5 -0
  296. nat_engine-1.dist-info/entry_points.txt +4 -0
  297. nat_engine-1.dist-info/licenses/LICENSE +651 -0
  298. nat_engine-1.dist-info/licenses/NOTICE +178 -0
  299. nat_engine-1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1086 @@
1
+ # Copyright (C) 2026 Brad Guider
2
+ # This file is part of NAT (Neural Agent Testing Framework).
3
+ # Licensed under the AGPL-3.0. See LICENSE for details.
4
+ # Commercial licensing available — see COMMERCIAL_LICENSE.md.
5
+
6
+ """AutonomousTestLoopAgent — Phase 5 end-to-end autonomous functional testing.
7
+
8
+ Closes the loop: **crawl → generate scenarios → execute → analyze → feed back
9
+ into beliefs → re-generate → repeat** — all triggered by a single ``run_loop()``
10
+ call.
11
+
12
+ The agent **composes** (not inherits) all Phase 1–4 components:
13
+
14
+ * :class:`~mannf.core.agents.web_crawler_agent.WebCrawlerAgent` — BFS crawl
15
+ * :class:`~mannf.core.browser.scenario_generator.ScenarioConfigGenerator` — scenario generation
16
+ * :class:`~mannf.core.functional_orchestrator.FunctionalTestOrchestrator` — browser execution
17
+ * :class:`~mannf.core.prioritization.adaptive_controller.AdaptiveController` — belief feedback
18
+
19
+ Convergence criteria (first one met stops the loop):
20
+ * ``max_iterations`` reached
21
+ * ``max_duration_s`` time budget exhausted
22
+ * No new failures in last ``stability_threshold`` consecutive iterations
23
+ * (future) coverage target met
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import logging
30
+ import time
31
+ import uuid
32
+ from datetime import datetime, timezone
33
+ from typing import Any, Dict, List, Optional, Set
34
+ from urllib.parse import urlparse
35
+
36
+ from mannf.core.agents.autonomous_loop_models import AutonomousRunReport, LoopIterationResult
37
+ from mannf.core.messaging.bus import MessageBus
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ def _page_key(url: str, base_url: str) -> str:
43
+ """Normalize a URL to a page-relative path for belief state keys."""
44
+ try:
45
+ base = urlparse(base_url)
46
+ parsed = urlparse(url)
47
+ if parsed.netloc == base.netloc or not parsed.netloc:
48
+ return parsed.path or "/"
49
+ return url
50
+ except Exception: # noqa: BLE001
51
+ return url
52
+
53
+
54
+ class AutonomousTestLoopAgent:
55
+ """Autonomous end-to-end functional test loop agent.
56
+
57
+ Parameters
58
+ ----------
59
+ target_url:
60
+ Root URL of the web application under test.
61
+ max_iterations:
62
+ Hard cap on loop iterations.
63
+ max_duration_s:
64
+ Wall-clock time budget in seconds.
65
+ stability_threshold:
66
+ Stop after this many consecutive iterations with no new failures.
67
+ strategy:
68
+ Scenario selection strategy: ``"all"``, ``"high_risk"``,
69
+ ``"new_changed"``, or ``"belief_targeted"``.
70
+ risk_threshold:
71
+ Minimum ``risk_score`` for ``"high_risk"`` strategy.
72
+ max_pages:
73
+ Maximum pages to crawl per iteration.
74
+ max_depth:
75
+ Maximum BFS depth for the crawler.
76
+ num_browser_agents:
77
+ Number of parallel browser executor agents.
78
+ headless:
79
+ Run browsers in headless mode.
80
+ llm_provider:
81
+ Optional LLM provider for AI-enhanced scenario generation.
82
+ recrawl_every:
83
+ Re-crawl (expensive) every N iterations; between re-crawls only
84
+ re-generate scenarios from the cached discovery model.
85
+ enable_webhook:
86
+ Whether to POST progress updates to ``webhook_url``.
87
+ webhook_url:
88
+ URL to receive progress webhook POSTs.
89
+ enable_security_scenarios:
90
+ When ``True``, include browser-level security test scenarios
91
+ (XSS, SQL injection, path traversal, SSRF, etc.) alongside
92
+ the standard functional scenarios.
93
+ """
94
+
95
+ def __init__(
96
+ self,
97
+ target_url: str,
98
+ *,
99
+ max_iterations: int = 5,
100
+ max_duration_s: float = 600.0,
101
+ stability_threshold: int = 2,
102
+ strategy: str = "all",
103
+ risk_threshold: float = 0.6,
104
+ max_pages: int = 50,
105
+ max_depth: int = 5,
106
+ num_browser_agents: int = 2,
107
+ headless: bool = True,
108
+ llm_provider: Any | None = None,
109
+ recrawl_every: int = 3,
110
+ enable_webhook: bool = False,
111
+ webhook_url: str = "",
112
+ enable_security_scenarios: bool = False,
113
+ seeded_scenarios: list[dict[str, Any]] | None = None,
114
+ ) -> None:
115
+ self.target_url = target_url
116
+ self.max_iterations = max_iterations
117
+ self.max_duration_s = max_duration_s
118
+ self.stability_threshold = stability_threshold
119
+ self.strategy = strategy
120
+ self.risk_threshold = risk_threshold
121
+ self.max_pages = max_pages
122
+ self.max_depth = max_depth
123
+ self.num_browser_agents = num_browser_agents
124
+ self.headless = headless
125
+ self.llm_provider = llm_provider
126
+ self.recrawl_every = recrawl_every
127
+ self.enable_webhook = enable_webhook
128
+ self.webhook_url = webhook_url
129
+ self.enable_security_scenarios = enable_security_scenarios
130
+ # Pre-seeded scenarios injected before the first crawl (Phase 7.2 zero cold-start)
131
+ self.seeded_scenarios: list[dict[str, Any]] = list(seeded_scenarios or [])
132
+
133
+ # Internal state
134
+ self._run_id = str(uuid.uuid4())
135
+ self._stop_requested = False
136
+ self._pause_requested = False
137
+ self._discovery_model: Any | None = None # cached between re-crawl intervals
138
+ self._belief_state: Optional[Any] = None # lazy-created on first crawl
139
+ self._seen_failure_keys: Set[str] = set() # for dedup across iterations
140
+ self._consecutive_stable = 0
141
+ self._all_unique_failures: List[Dict[str, Any]] = []
142
+ self._covered_pages: Set[str] = set()
143
+ self._last_heartbeat_at: Optional[datetime] = None
144
+ self._paused_iteration: int = 0 # iteration at which run was paused
145
+ # Browser-level security findings accumulated across iterations
146
+ self._browser_security_findings: List[Dict[str, Any]] = []
147
+ # Belief evolution snapshots — one per iteration (Phase 6.5)
148
+ self._belief_snapshots: List[Dict[str, Any]] = []
149
+ # LLM root cause suggestions (Phase 6.5)
150
+ self._root_cause_suggestions: List[Dict[str, Any]] = []
151
+ # Coverage gaps — pages blocked by auth/CAPTCHA/timeout/unreachable (Phase 7.4)
152
+ self._coverage_gaps: List[Dict[str, Any]] = []
153
+
154
+ # ------------------------------------------------------------------
155
+ # Public API
156
+ # ------------------------------------------------------------------
157
+
158
+ @property
159
+ def run_id(self) -> str:
160
+ return self._run_id
161
+
162
+ @property
163
+ def last_heartbeat_at(self) -> Optional[datetime]:
164
+ """The UTC datetime of the last iteration heartbeat, or ``None``."""
165
+ return self._last_heartbeat_at
166
+
167
+ def request_stop(self) -> None:
168
+ """Signal the loop to stop gracefully after the current phase."""
169
+ self._stop_requested = True
170
+
171
+ def request_pause(self) -> None:
172
+ """Signal the loop to pause gracefully after the current iteration.
173
+
174
+ The run status will be set to ``"paused"`` and the agent's iteration
175
+ state serialised so it can be resumed later via :meth:`resume`.
176
+ """
177
+ self._pause_requested = True
178
+
179
+ def get_pause_state(self) -> Dict[str, Any]:
180
+ """Return a JSON-serialisable snapshot of the current agent state.
181
+
182
+ Used to persist pause state to the database so the run can be
183
+ reconstructed with :meth:`resume`.
184
+ """
185
+ belief_data: Optional[Dict[str, Any]] = None
186
+ if self._belief_state is not None:
187
+ belief_data = {
188
+ "services": self._belief_state.services,
189
+ "initial_belief": self._belief_state.initial_belief,
190
+ "fault_likelihood": dict(self._belief_state.fault_likelihood),
191
+ "confidence": dict(self._belief_state.confidence),
192
+ }
193
+
194
+ discovery_data: Optional[Dict[str, Any]] = None
195
+ if self._discovery_model is not None:
196
+ try:
197
+ discovery_data = self._discovery_model.to_dict()
198
+ except Exception: # noqa: BLE001
199
+ discovery_data = None
200
+
201
+ return {
202
+ "run_id": self._run_id,
203
+ "target_url": self.target_url,
204
+ "strategy": self.strategy,
205
+ "max_iterations": self.max_iterations,
206
+ "max_duration_s": self.max_duration_s,
207
+ "stability_threshold": self.stability_threshold,
208
+ "risk_threshold": self.risk_threshold,
209
+ "max_pages": self.max_pages,
210
+ "max_depth": self.max_depth,
211
+ "recrawl_every": self.recrawl_every,
212
+ "paused_iteration": self._paused_iteration,
213
+ "consecutive_stable": self._consecutive_stable,
214
+ "seen_failure_keys": list(self._seen_failure_keys),
215
+ "all_unique_failures": self._all_unique_failures,
216
+ "covered_pages": list(self._covered_pages),
217
+ "belief_state": belief_data,
218
+ "discovery_model": discovery_data,
219
+ }
220
+
221
+ @classmethod
222
+ def resume(cls, pause_state: Dict[str, Any]) -> "AutonomousTestLoopAgent":
223
+ """Reconstruct an agent from a previously saved pause state.
224
+
225
+ Parameters
226
+ ----------
227
+ pause_state:
228
+ A dict previously returned by :meth:`get_pause_state`.
229
+
230
+ Returns
231
+ -------
232
+ AutonomousTestLoopAgent
233
+ A new agent instance with state restored, ready to continue
234
+ from the paused iteration.
235
+ """
236
+ agent = cls(
237
+ target_url=pause_state["target_url"],
238
+ max_iterations=pause_state.get("max_iterations", 5),
239
+ max_duration_s=pause_state.get("max_duration_s", 600.0),
240
+ stability_threshold=pause_state.get("stability_threshold", 2),
241
+ strategy=pause_state.get("strategy", "all"),
242
+ risk_threshold=pause_state.get("risk_threshold", 0.6),
243
+ max_pages=pause_state.get("max_pages", 50),
244
+ max_depth=pause_state.get("max_depth", 5),
245
+ recrawl_every=pause_state.get("recrawl_every", 3),
246
+ )
247
+ # Restore run identity and iteration state
248
+ agent._run_id = pause_state.get("run_id", agent._run_id)
249
+ agent._consecutive_stable = pause_state.get("consecutive_stable", 0)
250
+ agent._seen_failure_keys = set(pause_state.get("seen_failure_keys", []))
251
+ agent._all_unique_failures = pause_state.get("all_unique_failures", [])
252
+ agent._covered_pages = set(pause_state.get("covered_pages", []))
253
+ agent._paused_iteration = pause_state.get("paused_iteration", 0)
254
+
255
+ # Restore belief state
256
+ belief_data = pause_state.get("belief_state")
257
+ if belief_data:
258
+ try:
259
+ from mannf.core.agents.belief_state import BeliefState # noqa: PLC0415
260
+
261
+ bs = BeliefState(
262
+ services=belief_data.get("services", []),
263
+ initial_belief=belief_data.get("initial_belief", 0.5),
264
+ )
265
+ bs.fault_likelihood.update(belief_data.get("fault_likelihood", {}))
266
+ bs.confidence.update(belief_data.get("confidence", {}))
267
+ agent._belief_state = bs
268
+ except Exception: # noqa: BLE001
269
+ logger.warning(
270
+ "AutonomousTestLoopAgent [%s]: could not restore belief state",
271
+ agent._run_id[:8],
272
+ )
273
+
274
+ # Restore discovery model
275
+ discovery_data = pause_state.get("discovery_model")
276
+ if discovery_data:
277
+ try:
278
+ from mannf.core.browser.discovery_model import DiscoveryModel # noqa: PLC0415
279
+ import dataclasses # noqa: PLC0415
280
+
281
+ def _from_dict(cls_: Any, data: Any) -> Any:
282
+ if not dataclasses.is_dataclass(cls_) or not isinstance(data, dict):
283
+ return data
284
+ fields = {f.name: f for f in dataclasses.fields(cls_)}
285
+ kwargs = {}
286
+ for name, fld in fields.items():
287
+ if name not in data:
288
+ continue
289
+ val = data[name]
290
+ origin = getattr(fld.type, "__origin__", None)
291
+ if origin is list and val is not None:
292
+ inner = fld.type.__args__[0] if fld.type.__args__ else None
293
+ if inner and dataclasses.is_dataclass(inner):
294
+ val = [_from_dict(inner, v) for v in val]
295
+ kwargs[name] = val
296
+ return cls_(**kwargs)
297
+
298
+ agent._discovery_model = _from_dict(DiscoveryModel, discovery_data)
299
+ except Exception: # noqa: BLE001
300
+ logger.warning(
301
+ "AutonomousTestLoopAgent [%s]: could not restore discovery model",
302
+ agent._run_id[:8],
303
+ )
304
+
305
+ return agent
306
+
307
+ async def run_loop(self) -> AutonomousRunReport:
308
+ """Execute the full autonomous test loop.
309
+
310
+ Returns
311
+ -------
312
+ AutonomousRunReport
313
+ Structured results for the complete run.
314
+ """
315
+ started_at = datetime.now(timezone.utc).isoformat()
316
+ loop_start = time.monotonic()
317
+
318
+ report = AutonomousRunReport(
319
+ run_id=self._run_id,
320
+ target_url=self.target_url,
321
+ strategy=self.strategy,
322
+ total_iterations=0,
323
+ total_scenarios_executed=0,
324
+ total_passed=0,
325
+ total_failed=0,
326
+ total_flaky=0,
327
+ started_at=started_at,
328
+ status="running",
329
+ )
330
+
331
+ stop_reason = "max_iterations"
332
+
333
+ try:
334
+ for iteration in range(1, self.max_iterations + 1):
335
+ if self._stop_requested:
336
+ stop_reason = "stopped"
337
+ break
338
+
339
+ if self._pause_requested:
340
+ stop_reason = "paused"
341
+ self._paused_iteration = iteration
342
+ break
343
+
344
+ elapsed = time.monotonic() - loop_start
345
+ if elapsed >= self.max_duration_s:
346
+ stop_reason = "max_duration"
347
+ break
348
+
349
+ logger.info(
350
+ "AutonomousTestLoopAgent [%s]: starting iteration %d/%d",
351
+ self._run_id[:8],
352
+ iteration,
353
+ self.max_iterations,
354
+ )
355
+
356
+ iter_result = await self._run_iteration(
357
+ iteration=iteration,
358
+ elapsed_budget=self.max_duration_s - elapsed,
359
+ )
360
+ report.iterations.append(iter_result)
361
+ report.total_iterations = iteration
362
+ report.total_scenarios_executed += iter_result.scenarios_executed
363
+ report.total_passed += iter_result.passed
364
+ report.total_failed += iter_result.failed
365
+ report.total_flaky += iter_result.flaky
366
+
367
+ # Update heartbeat timestamp after each completed iteration
368
+ self._last_heartbeat_at = datetime.now(timezone.utc)
369
+
370
+ # Broadcast iteration progress via WebSocket
371
+ await self._broadcast_iteration(report, iter_result)
372
+
373
+ # Convergence check — stability
374
+ if iter_result.new_failures == 0:
375
+ self._consecutive_stable += 1
376
+ else:
377
+ self._consecutive_stable = 0
378
+
379
+ if self._consecutive_stable >= self.stability_threshold:
380
+ stop_reason = "stable"
381
+ break
382
+
383
+ # Check time budget again after the iteration
384
+ if time.monotonic() - loop_start >= self.max_duration_s:
385
+ stop_reason = "max_duration"
386
+ break
387
+
388
+ # Finalize report
389
+ completed_at = datetime.now(timezone.utc).isoformat()
390
+ duration_s = round(time.monotonic() - loop_start, 2)
391
+
392
+ if stop_reason == "paused":
393
+ report.status = "paused"
394
+ else:
395
+ report.status = "complete"
396
+ report.stop_reason = stop_reason
397
+ report.completed_at = completed_at
398
+ report.duration_s = duration_s
399
+ report.unique_failures = list(self._all_unique_failures)
400
+ # Attach belief snapshots and root cause suggestions (Phase 6.5)
401
+ report.belief_snapshots = list(self._belief_snapshots)
402
+ report.root_cause_suggestions = list(self._root_cause_suggestions)
403
+ # Attach coverage gaps (Phase 7.4)
404
+ report.coverage_gaps = list(self._coverage_gaps)
405
+ # Build security coverage metrics
406
+ sec_findings = self._browser_security_findings
407
+ owasp_categories: set[str] = set()
408
+ for f in sec_findings:
409
+ cat = f.get("owasp_category", "")
410
+ if cat:
411
+ owasp_categories.add(cat)
412
+ report.coverage_summary = {
413
+ "pages_tested": len(self._covered_pages),
414
+ "total_pages_crawled": (
415
+ len(self._discovery_model.pages) if self._discovery_model else 0
416
+ ),
417
+ "security_fields_tested": len({
418
+ f.get("endpoint", "") for f in sec_findings
419
+ }),
420
+ "security_payloads_attempted": sum(
421
+ 1 for s in report.iterations
422
+ for _ in range(getattr(s, "scenarios_executed", 0))
423
+ ) if self.enable_security_scenarios else 0,
424
+ "security_exploits_found": len(sec_findings),
425
+ "owasp_coverage": sorted(owasp_categories),
426
+ }
427
+
428
+ except Exception as exc: # noqa: BLE001
429
+ logger.exception("AutonomousTestLoopAgent [%s]: fatal error", self._run_id[:8])
430
+ report.status = "failed"
431
+ report.error = str(exc)
432
+ report.stop_reason = "error"
433
+ report.completed_at = datetime.now(timezone.utc).isoformat()
434
+ report.duration_s = round(time.monotonic() - loop_start, 2)
435
+
436
+ await self._broadcast_complete(report)
437
+ logger.info(
438
+ "AutonomousTestLoopAgent [%s]: finished — %s after %d iterations",
439
+ self._run_id[:8],
440
+ report.stop_reason,
441
+ report.total_iterations,
442
+ )
443
+ return report
444
+
445
+ # ------------------------------------------------------------------
446
+ # Per-iteration pipeline
447
+ # ------------------------------------------------------------------
448
+
449
+ async def _run_iteration(
450
+ self,
451
+ iteration: int,
452
+ elapsed_budget: float,
453
+ ) -> LoopIterationResult:
454
+ """Run one full crawl → generate → filter → execute → analyze → feedback cycle."""
455
+ iter_start = time.monotonic()
456
+
457
+ # ------------------------------------------------------------------
458
+ # Phase 1: Crawl (every recrawl_every iterations, or on first iteration)
459
+ # ------------------------------------------------------------------
460
+ should_recrawl = (iteration == 1) or ((iteration - 1) % self.recrawl_every == 0)
461
+ if should_recrawl:
462
+ self._discovery_model = await self._crawl()
463
+
464
+ # Bail out early if crawl produced nothing (graceful degradation)
465
+ if self._discovery_model is None or not self._discovery_model.pages:
466
+ logger.warning(
467
+ "AutonomousTestLoopAgent [%s]: iteration %d — no pages discovered, skipping",
468
+ self._run_id[:8],
469
+ iteration,
470
+ )
471
+ return LoopIterationResult(
472
+ iteration=iteration,
473
+ scenarios_generated=0,
474
+ scenarios_executed=0,
475
+ passed=0,
476
+ failed=0,
477
+ flaky=0,
478
+ new_failures=0,
479
+ coverage_pages=0,
480
+ duration_s=round(time.monotonic() - iter_start, 2),
481
+ )
482
+
483
+ # ------------------------------------------------------------------
484
+ # Phase 2: Generate scenarios
485
+ # ------------------------------------------------------------------
486
+ scenarios = await self._generate_scenarios()
487
+
488
+ # Merge pre-seeded scenarios on iteration 1 (zero cold-start)
489
+ if iteration == 1 and self.seeded_scenarios:
490
+ logger.info(
491
+ "AutonomousTestLoopAgent [%s]: merging %d pre-seeded scenarios into iteration 1",
492
+ self._run_id[:8],
493
+ len(self.seeded_scenarios),
494
+ )
495
+ # Prepend seeds so strategy filter can prioritise them
496
+ scenarios = list(self.seeded_scenarios) + scenarios
497
+
498
+ # ------------------------------------------------------------------
499
+ # Phase 3: Filter scenarios
500
+ # ------------------------------------------------------------------
501
+ previously_tested: Set[str] = set()
502
+ for prev_iter in self._get_previous_iteration_pages():
503
+ previously_tested.add(prev_iter)
504
+
505
+ selected = self._filter_scenarios(scenarios, previously_tested)
506
+ scenarios_generated = len(scenarios)
507
+
508
+ if not selected:
509
+ logger.info(
510
+ "AutonomousTestLoopAgent [%s]: iteration %d — no scenarios selected by strategy=%s",
511
+ self._run_id[:8],
512
+ iteration,
513
+ self.strategy,
514
+ )
515
+ return LoopIterationResult(
516
+ iteration=iteration,
517
+ scenarios_generated=scenarios_generated,
518
+ scenarios_executed=0,
519
+ passed=0,
520
+ failed=0,
521
+ flaky=0,
522
+ new_failures=0,
523
+ coverage_pages=0,
524
+ duration_s=round(time.monotonic() - iter_start, 2),
525
+ )
526
+
527
+ # ------------------------------------------------------------------
528
+ # Phase 4: Execute
529
+ # ------------------------------------------------------------------
530
+ orch_report = await self._execute_scenarios(selected)
531
+
532
+ # ------------------------------------------------------------------
533
+ # Phase 5: Analyze
534
+ # ------------------------------------------------------------------
535
+ passed, failed, flaky, task_details = self._analyze_report(orch_report)
536
+
537
+ # Track covered pages
538
+ for task in selected:
539
+ page_key = _page_key(task.get("url", ""), self.target_url)
540
+ self._covered_pages.add(page_key)
541
+
542
+ # Track coverage gaps from blocked/unreachable responses
543
+ self._collect_coverage_gaps(orch_report, iteration)
544
+
545
+ # ------------------------------------------------------------------
546
+ # Phase 5b: Reflection analysis for security scenarios
547
+ # ------------------------------------------------------------------
548
+ if self.enable_security_scenarios:
549
+ security_selected = [
550
+ s for s in selected
551
+ if s.get("metadata", {}).get("scenario_type") == "security"
552
+ ]
553
+ if security_selected:
554
+ from mannf.core.browser.reflection_analyzer import analyze_security_results # noqa: PLC0415
555
+ result_items = orch_report.get("items", []) if isinstance(orch_report, dict) else []
556
+ new_sec_findings = analyze_security_results(security_selected, result_items)
557
+ if new_sec_findings:
558
+ logger.info(
559
+ "AutonomousTestLoopAgent [%s]: iteration %d — %d browser security findings",
560
+ self._run_id[:8],
561
+ iteration,
562
+ len(new_sec_findings),
563
+ )
564
+ self._browser_security_findings.extend(new_sec_findings)
565
+
566
+ # ------------------------------------------------------------------
567
+ # Phase 6: Feedback — update beliefs
568
+ # ------------------------------------------------------------------
569
+ prev_unique_count = len(self._all_unique_failures)
570
+ new_failures = self._update_beliefs(task_details)
571
+
572
+ # ------------------------------------------------------------------
573
+ # Phase 6b: Persist belief snapshot for this iteration (Phase 6.5)
574
+ # ------------------------------------------------------------------
575
+ self._persist_belief_snapshot(iteration)
576
+
577
+ # ------------------------------------------------------------------
578
+ # Phase 6c: LLM root cause analysis for new failures (Phase 6.5)
579
+ # ------------------------------------------------------------------
580
+ if new_failures > 0:
581
+ newly_added = self._all_unique_failures[prev_unique_count:]
582
+ self._schedule_root_cause_analysis(newly_added)
583
+
584
+ # ------------------------------------------------------------------
585
+ # Phase 7: Fire-and-forget exporter hook (non-blocking)
586
+ # ------------------------------------------------------------------
587
+ if new_failures > 0:
588
+ newly_added = self._all_unique_failures[prev_unique_count:]
589
+ self._schedule_exporter_hook(newly_added)
590
+
591
+ iter_duration = round(time.monotonic() - iter_start, 2)
592
+
593
+ logger.info(
594
+ "AutonomousTestLoopAgent [%s]: iteration %d done — "
595
+ "generated=%d executed=%d passed=%d failed=%d flaky=%d new_failures=%d (%.1fs)",
596
+ self._run_id[:8],
597
+ iteration,
598
+ scenarios_generated,
599
+ len(selected),
600
+ passed,
601
+ failed,
602
+ flaky,
603
+ new_failures,
604
+ iter_duration,
605
+ )
606
+
607
+ return LoopIterationResult(
608
+ iteration=iteration,
609
+ scenarios_generated=scenarios_generated,
610
+ scenarios_executed=len(selected),
611
+ passed=passed,
612
+ failed=failed,
613
+ flaky=flaky,
614
+ new_failures=new_failures,
615
+ coverage_pages=len(self._covered_pages),
616
+ duration_s=iter_duration,
617
+ )
618
+
619
+ # ------------------------------------------------------------------
620
+ # Phase helpers
621
+ # ------------------------------------------------------------------
622
+
623
+ async def _crawl(self) -> Any | None:
624
+ """Run the WebCrawlerAgent and return the discovery model."""
625
+ from mannf.core.agents.web_crawler_agent import WebCrawlerAgent # noqa: PLC0415
626
+
627
+ bus = MessageBus()
628
+ crawler = WebCrawlerAgent(
629
+ agent_id=f"auto-crawler-{self._run_id[:8]}",
630
+ bus=bus,
631
+ service_names=[self.target_url],
632
+ base_url=self.target_url,
633
+ headless=self.headless,
634
+ max_pages=self.max_pages,
635
+ max_depth=self.max_depth,
636
+ )
637
+ try:
638
+ await crawler.start_browser()
639
+ try:
640
+ await crawler._run_crawl()
641
+ finally:
642
+ await crawler.stop_browser()
643
+ return crawler._discovery_model
644
+ except Exception as exc: # noqa: BLE001
645
+ logger.warning(
646
+ "AutonomousTestLoopAgent [%s]: crawl failed — %s",
647
+ self._run_id[:8],
648
+ exc,
649
+ )
650
+ return None
651
+
652
+ async def _generate_scenarios(self) -> list[dict[str, Any]]:
653
+ """Generate test scenarios from the current discovery model."""
654
+ from mannf.core.browser.scenario_generator import ScenarioConfigGenerator # noqa: PLC0415
655
+
656
+ if self._discovery_model is None:
657
+ return []
658
+
659
+ gen = ScenarioConfigGenerator(
660
+ self._discovery_model,
661
+ llm_provider=self.llm_provider,
662
+ include_security=self.enable_security_scenarios,
663
+ )
664
+ try:
665
+ if self.llm_provider is not None:
666
+ return await gen.generate_all_async()
667
+ return gen.generate_all()
668
+ except Exception as exc: # noqa: BLE001
669
+ logger.warning(
670
+ "AutonomousTestLoopAgent [%s]: scenario generation failed — %s",
671
+ self._run_id[:8],
672
+ exc,
673
+ )
674
+ return []
675
+
676
+ def _filter_scenarios(
677
+ self,
678
+ scenarios: list[dict[str, Any]],
679
+ previously_tested_pages: Set[str],
680
+ ) -> list[dict[str, Any]]:
681
+ """Apply the configured strategy to select which scenarios to execute."""
682
+ if self.strategy == "all":
683
+ return scenarios
684
+
685
+ if self.strategy == "high_risk":
686
+ return [
687
+ s for s in scenarios
688
+ if s.get("metadata", {}).get("risk_score", 0.0) >= self.risk_threshold
689
+ ]
690
+
691
+ if self.strategy == "new_changed":
692
+ filtered = []
693
+ for s in scenarios:
694
+ page_key = _page_key(s.get("url", ""), self.target_url)
695
+ if page_key not in previously_tested_pages:
696
+ filtered.append(s)
697
+ # Fall back to all if nothing is new
698
+ return filtered or scenarios
699
+
700
+ if self.strategy == "belief_targeted":
701
+ return self._belief_targeted_filter(scenarios)
702
+
703
+ # Unknown strategy — run all
704
+ logger.warning(
705
+ "AutonomousTestLoopAgent: unknown strategy %r, falling back to 'all'",
706
+ self.strategy,
707
+ )
708
+ return scenarios
709
+
710
+ def _belief_targeted_filter(
711
+ self,
712
+ scenarios: list[dict[str, Any]],
713
+ ) -> list[dict[str, Any]]:
714
+ """Use belief state fault_likelihood to rank and select top-N scenarios."""
715
+ if self._belief_state is None:
716
+ return scenarios
717
+
718
+ fault_likelihoods = self._belief_state.fault_likelihood
719
+
720
+ def _score(s: dict[str, Any]) -> float:
721
+ page_key = _page_key(s.get("url", ""), self.target_url)
722
+ return fault_likelihoods.get(page_key, 0.5)
723
+
724
+ ranked = sorted(scenarios, key=_score, reverse=True)
725
+ # Take top 50% by belief score, minimum 1
726
+ top_n = max(1, len(ranked) // 2)
727
+ return ranked[:top_n]
728
+
729
+ async def _execute_scenarios(
730
+ self,
731
+ tasks: list[dict[str, Any]],
732
+ ) -> dict[str, Any]:
733
+ """Run the selected scenario tasks through FunctionalTestOrchestrator."""
734
+ from mannf.core.functional_orchestrator import FunctionalTestOrchestrator # noqa: PLC0415
735
+
736
+ try:
737
+ orch = FunctionalTestOrchestrator(num_browser_agents=self.num_browser_agents)
738
+ return await orch.run(tasks)
739
+ except Exception as exc: # noqa: BLE001
740
+ logger.warning(
741
+ "AutonomousTestLoopAgent [%s]: execution failed — %s",
742
+ self._run_id[:8],
743
+ exc,
744
+ )
745
+ # Return a minimal empty report so analysis phase doesn't crash
746
+ return {"passed": 0, "failed": len(tasks), "total": len(tasks), "tasks": []}
747
+
748
+ def _analyze_report(
749
+ self,
750
+ orch_report: dict[str, Any],
751
+ ) -> tuple[int, int, int, list[dict[str, Any]]]:
752
+ """Extract pass/fail/flaky counts and per-task details from orchestrator report.
753
+
754
+ Returns
755
+ -------
756
+ tuple[int, int, int, list[dict]]
757
+ ``(passed, failed, flaky, task_details)``
758
+ """
759
+ passed = int(orch_report.get("passed", 0))
760
+ failed = int(orch_report.get("failed", 0))
761
+ flaky = 0
762
+
763
+ raw_tasks: list[dict[str, Any]] = orch_report.get("tasks", [])
764
+ task_details: list[dict[str, Any]] = []
765
+
766
+ for t in raw_tasks:
767
+ task_id = t.get("task_id", "")
768
+ task_url = t.get("url", "")
769
+ outcome = t.get("outcome", "")
770
+ error = t.get("error", "")
771
+ is_flaky = t.get("flaky", False) or outcome == "flaky"
772
+
773
+ if is_flaky:
774
+ flaky += 1
775
+ # Flaky tasks were counted in passed or failed by orchestrator;
776
+ # don't double-count, just record for feedback
777
+ task_details.append(
778
+ {
779
+ "task_id": task_id,
780
+ "url": task_url,
781
+ "passed": outcome == "passed" or t.get("passed", False),
782
+ "failed": outcome == "failed" or (not t.get("passed", True)),
783
+ "flaky": is_flaky,
784
+ "error": error,
785
+ }
786
+ )
787
+
788
+ return passed, failed, flaky, task_details
789
+
790
+ def _collect_coverage_gaps(
791
+ self,
792
+ orch_report: dict[str, Any],
793
+ iteration: int,
794
+ ) -> None:
795
+ """Scan the orchestrator report for blocked/unreachable pages and record them.
796
+
797
+ Categories detected:
798
+ - **auth-blocked** — HTTP 401/403 responses.
799
+ - **captcha-blocked** — responses containing CAPTCHA indicators.
800
+ - **timeout** — tasks that exceeded the time budget.
801
+ - **connection-refused** — DNS/TCP failures.
802
+
803
+ Entries are deduplicated by URL+category so the same gap is not added
804
+ multiple times across iterations.
805
+
806
+ Parameters
807
+ ----------
808
+ orch_report:
809
+ Raw dict returned by :meth:`_execute_scenarios`.
810
+ iteration:
811
+ Current iteration number (included in each gap entry for context).
812
+ """
813
+ _REMEDIATION: Dict[str, str] = {
814
+ "auth-blocked": (
815
+ "Provide valid credentials via the `auth_config` option or configure "
816
+ "session cookies to allow NAT to access protected pages."
817
+ ),
818
+ "captcha-blocked": (
819
+ "Disable CAPTCHA in your test environment, or use a bypass token "
820
+ "to allow automated access during testing."
821
+ ),
822
+ "timeout": (
823
+ "Increase the `page_timeout_ms` budget or investigate slow-loading "
824
+ "pages that may indicate performance regressions."
825
+ ),
826
+ "connection-refused": (
827
+ "Verify the target URL is reachable from the test runner and that "
828
+ "the service is healthy before starting NAT."
829
+ ),
830
+ }
831
+
832
+ seen_keys: Set[str] = {
833
+ f"{g['url']}::{g['category']}" for g in self._coverage_gaps
834
+ }
835
+
836
+ raw_tasks: list[dict[str, Any]] = orch_report.get("tasks", [])
837
+ for task in raw_tasks:
838
+ url = task.get("url", "")
839
+ error = (task.get("error") or "").lower()
840
+ status_code = task.get("status_code")
841
+
842
+ category: Optional[str] = None
843
+
844
+ if status_code in (401, 403):
845
+ category = "auth-blocked"
846
+ elif "captcha" in error or "challenge" in error:
847
+ category = "captcha-blocked"
848
+ elif "timeout" in error or "timed out" in error:
849
+ category = "timeout"
850
+ elif (
851
+ "connection refused" in error
852
+ or "net::err_connection_refused" in error
853
+ or "econnrefused" in error
854
+ ):
855
+ category = "connection-refused"
856
+
857
+ if category is None:
858
+ continue
859
+
860
+ gap_key = f"{url}::{category}"
861
+ if gap_key in seen_keys:
862
+ continue
863
+ seen_keys.add(gap_key)
864
+
865
+ self._coverage_gaps.append(
866
+ {
867
+ "url": url,
868
+ "category": category,
869
+ "status_code": status_code,
870
+ "error_detail": task.get("error", ""),
871
+ "iteration": iteration,
872
+ "remediation": _REMEDIATION[category],
873
+ }
874
+ )
875
+
876
+ def _update_beliefs(self, task_details: list[dict[str, Any]]) -> int:
877
+ """Feed execution results back to BeliefState.
878
+
879
+ * Failed → increase fault_likelihood for that page
880
+ * Passed → decrease fault_likelihood
881
+ * Flaky → moderate increase
882
+
883
+ Returns
884
+ -------
885
+ int
886
+ Number of new (previously unseen) failures detected.
887
+ """
888
+ from mannf.core.agents.belief_state import BeliefState # noqa: PLC0415
889
+
890
+ # Lazily initialise belief state on first call
891
+ if self._belief_state is None:
892
+ self._belief_state = BeliefState(services=[])
893
+
894
+ new_failures_count = 0
895
+
896
+ for t in task_details:
897
+ page_key = _page_key(t.get("url", ""), self.target_url)
898
+
899
+ if t.get("flaky"):
900
+ self._belief_state.update(page_key, 0.7)
901
+ elif t.get("failed"):
902
+ self._belief_state.update(page_key, 1.0)
903
+
904
+ # Track unique failures (dedup by url + error)
905
+ failure_key = f"{page_key}::{t.get('error', '')}"
906
+ if failure_key not in self._seen_failure_keys:
907
+ self._seen_failure_keys.add(failure_key)
908
+ new_failures_count += 1
909
+ self._all_unique_failures.append(
910
+ {
911
+ "url": t.get("url", ""),
912
+ "page": page_key,
913
+ "error": t.get("error", ""),
914
+ "task_id": t.get("task_id", ""),
915
+ }
916
+ )
917
+ elif t.get("passed"):
918
+ self._belief_state.update(page_key, 0.0)
919
+
920
+ return new_failures_count
921
+
922
+ def _schedule_exporter_hook(self, new_failures: List[Dict[str, Any]]) -> None:
923
+ """Schedule a fire-and-forget background task to export new failures.
924
+
925
+ This is intentionally non-blocking — filing failures never crash or
926
+ delay the autonomous loop.
927
+ """
928
+ if not new_failures:
929
+ return
930
+ try:
931
+ loop = asyncio.get_running_loop()
932
+ loop.create_task(self._export_new_failures(list(new_failures)))
933
+ except RuntimeError:
934
+ # No running event loop (e.g. called from a sync context / tests)
935
+ # — skip silently; filing is best-effort only.
936
+ pass
937
+
938
+ async def _export_new_failures(self, failures: List[Dict[str, Any]]) -> None:
939
+ """Best-effort: notify the dashboard's auto-filing pipeline about new failures."""
940
+ try:
941
+ from mannf.product.dashboard.app import _auto_file_bugs # noqa: PLC0415
942
+ from mannf.core.agents.autonomous_loop_models import AutonomousRunReport # noqa: PLC0415
943
+
944
+ # Build a minimal report with just the new failures so the dashboard
945
+ # pipeline can apply severity filtering and file tickets.
946
+ stub_report = AutonomousRunReport(
947
+ run_id=self._run_id,
948
+ target_url=self.target_url,
949
+ strategy=self.strategy,
950
+ total_iterations=0,
951
+ total_scenarios_executed=len(failures),
952
+ total_passed=0,
953
+ total_failed=len(failures),
954
+ total_flaky=0,
955
+ unique_failures=failures,
956
+ started_at="",
957
+ status="running",
958
+ )
959
+ await _auto_file_bugs(self._run_id, stub_report)
960
+ except Exception as exc: # noqa: BLE001
961
+ logger.debug(
962
+ "AutonomousTestLoopAgent [%s]: exporter hook failed (non-fatal): %s",
963
+ self._run_id[:8],
964
+ exc,
965
+ )
966
+
967
+ # ------------------------------------------------------------------
968
+ # Helpers
969
+ # ------------------------------------------------------------------
970
+
971
+ def _persist_belief_snapshot(self, iteration: int) -> None:
972
+ """Append a belief state snapshot for the current iteration (Phase 6.5)."""
973
+ if self._belief_state is None:
974
+ return
975
+ try:
976
+ snapshot = self._belief_state.export_snapshot(
977
+ run_id=self._run_id,
978
+ iteration=iteration,
979
+ )
980
+ self._belief_snapshots.append(snapshot)
981
+ except Exception as exc: # noqa: BLE001
982
+ logger.debug(
983
+ "AutonomousTestLoopAgent [%s]: belief snapshot failed (non-fatal): %s",
984
+ self._run_id[:8],
985
+ exc,
986
+ )
987
+
988
+ def _schedule_root_cause_analysis(self, new_failures: List[Dict[str, Any]]) -> None:
989
+ """Schedule a fire-and-forget background task to run LLM root cause analysis."""
990
+ if not new_failures or self.llm_provider is None:
991
+ return
992
+ try:
993
+ loop = asyncio.get_running_loop()
994
+ loop.create_task(self._run_root_cause_analysis(list(new_failures)))
995
+ except RuntimeError:
996
+ pass
997
+
998
+ async def _run_root_cause_analysis(self, failures: List[Dict[str, Any]]) -> None:
999
+ """Best-effort: call LLM root cause analyzer for new failures."""
1000
+ try:
1001
+ from mannf.core.diagnostics.root_cause_analyzer import RootCauseAnalyzer # noqa: PLC0415
1002
+
1003
+ analyzer = RootCauseAnalyzer(llm_provider=self.llm_provider)
1004
+ for failure in failures:
1005
+ page_key = failure.get("page", "")
1006
+ fault_likelihood = (
1007
+ self._belief_state.fault_likelihood.get(page_key, 1.0)
1008
+ if self._belief_state is not None
1009
+ else 1.0
1010
+ )
1011
+ # Collect belief history for this page from snapshots
1012
+ belief_history = [
1013
+ {
1014
+ "iteration": s.get("iteration"),
1015
+ "fault_likelihood": s.get("beliefs", {}).get(page_key),
1016
+ "timestamp": s.get("timestamp"),
1017
+ }
1018
+ for s in self._belief_snapshots
1019
+ if s.get("beliefs", {}).get(page_key) is not None
1020
+ ]
1021
+
1022
+ result = await analyzer.analyze(
1023
+ failure=failure,
1024
+ fault_likelihood=fault_likelihood,
1025
+ belief_history=belief_history,
1026
+ )
1027
+ if result is not None:
1028
+ suggestion = result.to_dict()
1029
+ suggestion["task_id"] = failure.get("task_id", "")
1030
+ suggestion["url"] = failure.get("url", "")
1031
+ suggestion["page"] = page_key
1032
+ self._root_cause_suggestions.append(suggestion)
1033
+ logger.debug(
1034
+ "AutonomousTestLoopAgent [%s]: root cause for %s — %s (confidence=%.2f)",
1035
+ self._run_id[:8],
1036
+ page_key,
1037
+ result.likely_cause[:80],
1038
+ result.confidence,
1039
+ )
1040
+ except Exception as exc: # noqa: BLE001
1041
+ logger.debug(
1042
+ "AutonomousTestLoopAgent [%s]: root cause analysis failed (non-fatal): %s",
1043
+ self._run_id[:8],
1044
+ exc,
1045
+ )
1046
+
1047
+ def _get_previous_iteration_pages(self) -> Set[str]:
1048
+ """Return the set of page keys that have already been covered."""
1049
+ return set(self._covered_pages)
1050
+
1051
+ async def _broadcast_iteration(
1052
+ self,
1053
+ report: AutonomousRunReport,
1054
+ iter_result: LoopIterationResult,
1055
+ ) -> None:
1056
+ """Broadcast an autonomous_iteration WebSocket event (best-effort)."""
1057
+ try:
1058
+ from mannf.product.dashboard.telemetry import broadcast_autonomous_iteration # noqa: PLC0415
1059
+
1060
+ await broadcast_autonomous_iteration(
1061
+ run_id=self._run_id,
1062
+ iteration=iter_result.iteration,
1063
+ passed=iter_result.passed,
1064
+ failed=iter_result.failed,
1065
+ flaky=iter_result.flaky,
1066
+ new_failures=iter_result.new_failures,
1067
+ scenarios_executed=iter_result.scenarios_executed,
1068
+ coverage_pages=iter_result.coverage_pages,
1069
+ )
1070
+ except Exception: # noqa: BLE001
1071
+ pass
1072
+
1073
+ async def _broadcast_complete(self, report: AutonomousRunReport) -> None:
1074
+ """Broadcast an autonomous_complete WebSocket event (best-effort)."""
1075
+ try:
1076
+ from mannf.product.dashboard.telemetry import broadcast_autonomous_complete # noqa: PLC0415
1077
+
1078
+ await broadcast_autonomous_complete(
1079
+ run_id=self._run_id,
1080
+ stop_reason=report.stop_reason,
1081
+ total_iterations=report.total_iterations,
1082
+ total_passed=report.total_passed,
1083
+ total_failed=report.total_failed,
1084
+ )
1085
+ except Exception: # noqa: BLE001
1086
+ pass