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,249 @@
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
+ """Test-generation agent.
7
+
8
+ The :class:`TestAgent` uses its neural network to generate prioritised test
9
+ cases for a given set of distributed service endpoints. It listens for
10
+ :attr:`MessageType.TEST_REQUEST` messages and responds with
11
+ :attr:`MessageType.TEST_CASE` messages.
12
+
13
+ It also listens for :attr:`MessageType.FEEDBACK` messages to update its
14
+ neural network with observed rewards, so that over time it learns to generate
15
+ test cases that are more likely to find defects.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import random
22
+ import string
23
+ import uuid
24
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set
25
+
26
+ import numpy as np
27
+
28
+ from mannf.core.agents.base import BaseAgent
29
+ from mannf.core.messaging.bus import MessageBus
30
+ from mannf.core.messaging.messages import Message, MessageType
31
+ from mannf.core.neural import NeuralNetwork
32
+ from mannf.core.testing.models import TestCase
33
+
34
+ if TYPE_CHECKING:
35
+ from mannf.llm.base import LLMProvider
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+
40
+ class TestAgent(BaseAgent):
41
+ """Generates adaptive test cases for distributed service endpoints.
42
+
43
+ Parameters
44
+ ----------
45
+ agent_id:
46
+ Unique agent identifier.
47
+ bus:
48
+ Shared message bus.
49
+ service_names:
50
+ Names of the services this agent can target.
51
+ feature_dim:
52
+ Dimensionality of the input feature vector fed to the NN.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ agent_id: str,
58
+ bus: MessageBus,
59
+ service_names: List[str],
60
+ feature_dim: int = 8,
61
+ llm_provider: Optional["LLMProvider"] = None,
62
+ ) -> None:
63
+ network = NeuralNetwork(
64
+ layer_sizes=[feature_dim, 32, 16, len(service_names)],
65
+ hidden_activation="relu",
66
+ learning_rate=0.01,
67
+ )
68
+ super().__init__(agent_id, bus, network)
69
+ self.service_names = service_names
70
+ self.feature_dim = feature_dim
71
+ self._generated_count = 0
72
+ self.llm_provider = llm_provider
73
+
74
+ @property
75
+ def subscribed_types(self) -> Set[MessageType]:
76
+ return {MessageType.TEST_REQUEST, MessageType.FEEDBACK}
77
+
78
+ # ------------------------------------------------------------------
79
+ # Message handling
80
+ # ------------------------------------------------------------------
81
+
82
+ async def _handle_message(self, message: Message) -> None:
83
+ if message.type == MessageType.TEST_REQUEST:
84
+ test_case = self._generate_test_case(context=message.payload)
85
+ await self._publish(
86
+ message.reply(MessageType.TEST_CASE, test_case, sender_id=self.agent_id)
87
+ )
88
+ elif message.type == MessageType.FEEDBACK:
89
+ self._apply_feedback(message.payload)
90
+
91
+ async def _on_idle(self) -> None:
92
+ # Proactively generate a test case every idle cycle
93
+ test_case = self._generate_test_case()
94
+ await self._publish(Message(MessageType.TEST_CASE, self.agent_id, test_case))
95
+
96
+ # ------------------------------------------------------------------
97
+ # Test generation
98
+ # ------------------------------------------------------------------
99
+
100
+ def _generate_test_case(
101
+ self, context: Optional[Dict[str, Any]] = None
102
+ ) -> TestCase:
103
+ """Use the neural network to select a target and build a test case."""
104
+ # Build a context feature vector
105
+ ctx_vec = self._context_to_features(context)
106
+
107
+ # Forward pass: get unnormalised scores for each service
108
+ assert self.network is not None
109
+ scores = self.network.predict(ctx_vec)
110
+
111
+ # Softmax to get a probability distribution over services
112
+ exp_scores = np.exp(scores - scores.max())
113
+ probs = exp_scores / exp_scores.sum()
114
+
115
+ # Sample from the distribution (exploration built into softmax)
116
+ target_idx = int(np.random.choice(len(self.service_names), p=probs))
117
+ target = self.service_names[target_idx]
118
+ priority = float(probs[target_idx])
119
+
120
+ inputs = self._generate_inputs(target, context)
121
+ self._generated_count += 1
122
+
123
+ return TestCase(
124
+ target=target,
125
+ inputs=inputs,
126
+ expected_behavior=f"Service {target!r} should return a successful response",
127
+ priority=priority,
128
+ metadata={"generator": self.agent_id, "generation": self._generated_count},
129
+ )
130
+
131
+ async def _generate_test_case_with_llm(
132
+ self, context: Optional[Dict[str, Any]] = None
133
+ ) -> TestCase:
134
+ """Generate a test case, using LLM for inputs 20% of the time when available."""
135
+ # Build a context feature vector
136
+ ctx_vec = self._context_to_features(context)
137
+
138
+ # Forward pass: get unnormalised scores for each service
139
+ assert self.network is not None
140
+ scores = self.network.predict(ctx_vec)
141
+
142
+ # Softmax to get a probability distribution over services
143
+ exp_scores = np.exp(scores - scores.max())
144
+ probs = exp_scores / exp_scores.sum()
145
+
146
+ # Sample from the distribution (exploration built into softmax)
147
+ target_idx = int(np.random.choice(len(self.service_names), p=probs))
148
+ target = self.service_names[target_idx]
149
+ priority = float(probs[target_idx])
150
+
151
+ # Use LLM 20% of the time when available
152
+ use_llm = (
153
+ self.llm_provider is not None
154
+ and self.llm_provider.is_available()
155
+ and random.random() < 0.20
156
+ )
157
+
158
+ if use_llm:
159
+ assert self.llm_provider is not None
160
+ try:
161
+ endpoint_info = {
162
+ "method": "POST",
163
+ "path": target,
164
+ "description": f"Service endpoint: {target}",
165
+ "parameters": [],
166
+ "schema": {},
167
+ }
168
+ raw_cases = await self.llm_provider.generate_test_cases(
169
+ endpoint_info, {}, n=1
170
+ )
171
+ if raw_cases and isinstance(raw_cases[0].get("inputs"), dict):
172
+ inputs = raw_cases[0]["inputs"]
173
+ self._generated_count += 1
174
+ return TestCase(
175
+ target=target,
176
+ inputs=inputs,
177
+ expected_behavior=raw_cases[0].get(
178
+ "expected_behavior",
179
+ f"Service {target!r} should return a successful response",
180
+ ),
181
+ priority=priority,
182
+ metadata={
183
+ "generator": "llm",
184
+ "generation": self._generated_count,
185
+ "agent_id": self.agent_id,
186
+ },
187
+ )
188
+ except Exception as exc: # noqa: BLE001
189
+ logger.warning("TestAgent %s: LLM input generation failed: %s", self.agent_id, exc)
190
+
191
+ inputs = self._generate_inputs(target, context)
192
+ self._generated_count += 1
193
+
194
+ return TestCase(
195
+ target=target,
196
+ inputs=inputs,
197
+ expected_behavior=f"Service {target!r} should return a successful response",
198
+ priority=priority,
199
+ metadata={"generator": self.agent_id, "generation": self._generated_count},
200
+ )
201
+
202
+ def _context_to_features(self, context: Optional[Dict[str, Any]]) -> np.ndarray:
203
+ """Convert an optional context dictionary into a feature vector."""
204
+ vec = np.zeros(self.feature_dim)
205
+ if context is None:
206
+ return vec
207
+ # Encode pass_rate if available
208
+ vec[0] = float(context.get("pass_rate", 0.5))
209
+ # Encode defect_detection_rate
210
+ vec[1] = float(context.get("defect_detection_rate", 0.0))
211
+ # Encode step count (normalised)
212
+ vec[2] = min(float(context.get("step_count", 0)) / 1000.0, 1.0)
213
+ # Hash target hint if present
214
+ target_hint = context.get("target_hint", "")
215
+ if target_hint and target_hint in self.service_names:
216
+ idx = self.service_names.index(target_hint)
217
+ vec[3] = idx / max(len(self.service_names) - 1, 1)
218
+ return vec
219
+
220
+ @staticmethod
221
+ def _generate_inputs(target: str, context: Optional[Dict[str, Any]]) -> Dict[str, Any]:
222
+ """Produce plausible random inputs for a service invocation."""
223
+ return {
224
+ "request_id": str(uuid.uuid4()),
225
+ "user_id": random.randint(1, 10_000),
226
+ "payload_size": random.randint(1, 1024),
227
+ "action": random.choice(["create", "read", "update", "delete", "list"]),
228
+ "auth_token": "".join(random.choices(string.ascii_letters, k=16)),
229
+ }
230
+
231
+ # ------------------------------------------------------------------
232
+ # Feedback / online learning
233
+ # ------------------------------------------------------------------
234
+
235
+ def _apply_feedback(self, feedback: Optional[Dict[str, Any]]) -> None:
236
+ """Update the network weights given a feedback payload."""
237
+ if not feedback:
238
+ return
239
+ try:
240
+ x = np.array(feedback["features"], dtype=float)
241
+ y = np.array(feedback["target"], dtype=float)
242
+ assert self.network is not None
243
+ self.network.train_step(x, y)
244
+ except (KeyError, TypeError, ValueError) as exc:
245
+ logger.warning("TestAgent %s: invalid feedback payload – %s", self.agent_id, exc)
246
+
247
+ @property
248
+ def generated_count(self) -> int:
249
+ return self._generated_count
@@ -0,0 +1,311 @@
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
+ """VisualRegressionAgent – detects visual regressions in functional test runs.
7
+
8
+ Listens for :attr:`~mannf.core.messaging.messages.MessageType.SCREENSHOT_CAPTURED`
9
+ events published by :class:`~mannf.core.agents.browser_executor_agent.BrowserExecutorAgent`,
10
+ compares each screenshot against the stored baseline, and publishes:
11
+
12
+ * :attr:`~MessageType.VISUAL_BASELINE_SAVED` when a new baseline is stored
13
+ (first run or ``update_baselines=True``).
14
+ * :attr:`~MessageType.VISUAL_DIFF_DETECTED` when a screenshot exceeds the
15
+ configured diff threshold.
16
+
17
+ Screenshot bytes must be deposited via :meth:`register_screenshot` before
18
+ (or immediately after) the :attr:`~MessageType.SCREENSHOT_CAPTURED` message
19
+ arrives so the agent can retrieve them for comparison.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import hashlib
25
+ import logging
26
+ import os
27
+ from typing import Any
28
+
29
+ from mannf.core.agents.bdi_agent import BDIAgent
30
+ from mannf.core.browser.visual_comparer import (
31
+ VisualComparisonResult,
32
+ compare_screenshots,
33
+ load_baseline,
34
+ save_baseline,
35
+ )
36
+ from mannf.core.messaging.bus import MessageBus
37
+ from mannf.core.messaging.messages import Message, MessageType
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+
42
+ class VisualRegressionAgent(BDIAgent):
43
+ """BDI agent that performs visual regression testing.
44
+
45
+ Parameters
46
+ ----------
47
+ agent_id:
48
+ Unique identifier for this agent.
49
+ bus:
50
+ Shared message bus.
51
+ service_names:
52
+ Services in the system under test (forwarded to :class:`BDIAgent`).
53
+ baseline_dir:
54
+ Directory where baseline PNG files are stored.
55
+ diff_threshold:
56
+ Maximum allowed diff percentage (0–100) before a visual regression is
57
+ flagged.
58
+ update_baselines:
59
+ When ``True``, every screenshot is saved as the new baseline instead
60
+ of being compared.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ agent_id: str,
66
+ bus: MessageBus,
67
+ service_names: list[str],
68
+ baseline_dir: str = ".nat/visual_baselines",
69
+ diff_threshold: float = 0.1,
70
+ update_baselines: bool = False,
71
+ ) -> None:
72
+ super().__init__(agent_id=agent_id, bus=bus, service_names=service_names)
73
+
74
+ self._baseline_dir = baseline_dir
75
+ self._diff_threshold = diff_threshold
76
+ self._update_baselines = update_baselines
77
+
78
+ # url -> latest PNG bytes deposited by browser agents / orchestrator
79
+ self._screenshot_registry: dict[str, bytes] = {}
80
+
81
+ # Tracking for the visual report
82
+ self._results: list[dict[str, Any]] = []
83
+ self._baselines_created = 0
84
+ self._tested_urls: set[str] = set()
85
+
86
+ # ------------------------------------------------------------------
87
+ # BDIAgent contract
88
+ # ------------------------------------------------------------------
89
+
90
+ @property
91
+ def subscribed_types(self) -> set[MessageType]:
92
+ return {
93
+ MessageType.SCREENSHOT_CAPTURED,
94
+ MessageType.INTERACTION_RESULT,
95
+ MessageType.BELIEF_UPDATE,
96
+ }
97
+
98
+ async def _handle_message(self, message: Message) -> None:
99
+ if message.type == MessageType.SCREENSHOT_CAPTURED:
100
+ await self._on_screenshot_captured(message)
101
+ elif message.type == MessageType.INTERACTION_RESULT:
102
+ self._on_interaction_result(message)
103
+ elif message.type == MessageType.BELIEF_UPDATE:
104
+ payload = message.payload or {}
105
+ peer_beliefs = payload.get("beliefs", {})
106
+ if peer_beliefs:
107
+ self._absorb_peer_beliefs(message.sender_id, peer_beliefs)
108
+
109
+ # ------------------------------------------------------------------
110
+ # Screenshot registry
111
+ # ------------------------------------------------------------------
112
+
113
+ def register_screenshot(self, url: str, png_bytes: bytes) -> None:
114
+ """Deposit screenshot bytes so they can be retrieved on the next
115
+ :attr:`~MessageType.SCREENSHOT_CAPTURED` message for *url*.
116
+
117
+ Parameters
118
+ ----------
119
+ url:
120
+ The page URL the screenshot was taken from.
121
+ png_bytes:
122
+ Raw PNG image bytes.
123
+ """
124
+ self._screenshot_registry[url] = png_bytes
125
+
126
+ # ------------------------------------------------------------------
127
+ # Message handlers
128
+ # ------------------------------------------------------------------
129
+
130
+ async def _on_screenshot_captured(self, message: Message) -> None:
131
+ payload = message.payload or {}
132
+ url: str = payload.get("url", "")
133
+
134
+ png_bytes = self._screenshot_registry.get(url)
135
+ if png_bytes is None:
136
+ logger.debug(
137
+ "VisualRegressionAgent %s: no screenshot bytes registered for %s — skipping",
138
+ self.agent_id, url,
139
+ )
140
+ return
141
+
142
+ await self.compare_with_baseline(url, png_bytes)
143
+
144
+ def _on_interaction_result(self, message: Message) -> None:
145
+ payload = message.payload or {}
146
+ url: str = payload.get("url", "")
147
+ if url:
148
+ self._tested_urls.add(url)
149
+
150
+ # ------------------------------------------------------------------
151
+ # Core comparison logic
152
+ # ------------------------------------------------------------------
153
+
154
+ async def compare_with_baseline(
155
+ self, url: str, screenshot: bytes
156
+ ) -> VisualComparisonResult:
157
+ """Compare *screenshot* against the stored baseline for *url*.
158
+
159
+ If no baseline exists, or :attr:`update_baselines` is ``True``, the
160
+ screenshot is saved as the new baseline and
161
+ :attr:`~MessageType.VISUAL_BASELINE_SAVED` is published.
162
+
163
+ Otherwise the screenshot is compared pixel-by-pixel; if the diff
164
+ exceeds :attr:`_diff_threshold`,
165
+ :attr:`~MessageType.VISUAL_DIFF_DETECTED` is published.
166
+
167
+ Parameters
168
+ ----------
169
+ url:
170
+ The page URL used to derive the baseline filename.
171
+ screenshot:
172
+ Raw PNG bytes of the current screenshot.
173
+
174
+ Returns
175
+ -------
176
+ VisualComparisonResult
177
+ The comparison outcome.
178
+ """
179
+ filename = self._url_to_filename(url)
180
+ baseline_path = os.path.join(self._baseline_dir, f"{filename}.png")
181
+
182
+ existing_baseline = load_baseline(baseline_path)
183
+
184
+ if existing_baseline is None or self._update_baselines:
185
+ # Save as new baseline
186
+ save_baseline(baseline_path, screenshot)
187
+ self._baselines_created += 1
188
+ logger.debug(
189
+ "VisualRegressionAgent %s: saved baseline for %s → %s",
190
+ self.agent_id, url, baseline_path,
191
+ )
192
+ await self._publish(
193
+ Message(
194
+ MessageType.VISUAL_BASELINE_SAVED,
195
+ sender_id=self.agent_id,
196
+ payload={"url": url, "baseline_path": baseline_path},
197
+ )
198
+ )
199
+ result = VisualComparisonResult(
200
+ matched=True,
201
+ diff_percentage=0.0,
202
+ diff_image=None,
203
+ baseline_size=(0, 0),
204
+ actual_size=(0, 0),
205
+ threshold=self._diff_threshold,
206
+ )
207
+ self._results.append(
208
+ {
209
+ "url": url,
210
+ "matched": True,
211
+ "diff_percentage": 0.0,
212
+ "baseline_path": baseline_path,
213
+ "baseline_created": True,
214
+ }
215
+ )
216
+ return result
217
+
218
+ # Compare against existing baseline
219
+ result = await compare_screenshots(
220
+ existing_baseline, screenshot, self._diff_threshold
221
+ )
222
+
223
+ if not result.matched:
224
+ logger.info(
225
+ "VisualRegressionAgent %s: visual diff detected for %s "
226
+ "(diff=%.2f%% > threshold=%.2f%%)",
227
+ self.agent_id, url, result.diff_percentage, self._diff_threshold,
228
+ )
229
+ await self._publish(
230
+ Message(
231
+ MessageType.VISUAL_DIFF_DETECTED,
232
+ sender_id=self.agent_id,
233
+ payload={
234
+ "url": url,
235
+ "diff_percentage": result.diff_percentage,
236
+ "threshold": self._diff_threshold,
237
+ "baseline_path": baseline_path,
238
+ },
239
+ )
240
+ )
241
+
242
+ # Persist diff image alongside the baseline
243
+ if result.diff_image is not None:
244
+ diff_path = os.path.join(self._baseline_dir, f"{filename}_diff.png")
245
+ save_baseline(diff_path, result.diff_image)
246
+ logger.debug(
247
+ "VisualRegressionAgent %s: diff image saved to %s",
248
+ self.agent_id, diff_path,
249
+ )
250
+
251
+ self._results.append(
252
+ {
253
+ "url": url,
254
+ "matched": result.matched,
255
+ "diff_percentage": result.diff_percentage,
256
+ "baseline_path": baseline_path,
257
+ "baseline_created": False,
258
+ }
259
+ )
260
+ return result
261
+
262
+ # ------------------------------------------------------------------
263
+ # Reporting
264
+ # ------------------------------------------------------------------
265
+
266
+ def get_visual_report(self) -> dict[str, Any]:
267
+ """Return a summary of all visual comparisons performed.
268
+
269
+ Returns
270
+ -------
271
+ dict
272
+ ``{total_comparisons, passed, failed, baselines_created,
273
+ results: [{url, matched, diff_percentage, baseline_path}...]}``
274
+ """
275
+ passed = sum(1 for r in self._results if r["matched"])
276
+ failed = len(self._results) - passed
277
+ return {
278
+ "total_comparisons": len(self._results),
279
+ "passed": passed,
280
+ "failed": failed,
281
+ "baselines_created": self._baselines_created,
282
+ "results": [
283
+ {
284
+ "url": r["url"],
285
+ "matched": r["matched"],
286
+ "diff_percentage": r["diff_percentage"],
287
+ "baseline_path": r["baseline_path"],
288
+ }
289
+ for r in self._results
290
+ ],
291
+ }
292
+
293
+ # ------------------------------------------------------------------
294
+ # Helpers
295
+ # ------------------------------------------------------------------
296
+
297
+ @staticmethod
298
+ def _url_to_filename(url: str) -> str:
299
+ """Derive a safe filesystem filename from *url* using a SHA-256 prefix.
300
+
301
+ Parameters
302
+ ----------
303
+ url:
304
+ Any URL string.
305
+
306
+ Returns
307
+ -------
308
+ str
309
+ A 16-character hexadecimal string safe for use as a filename.
310
+ """
311
+ return hashlib.sha256(url.encode()).hexdigest()[:16]