ai-testing-swarm 0.1.1__py3-none-any.whl → 0.1.11__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.
- ai_testing_swarm/__init__.py +1 -1
- ai_testing_swarm/agents/execution_agent.py +162 -40
- ai_testing_swarm/agents/learning_agent.py +6 -4
- ai_testing_swarm/agents/test_planner_agent.py +376 -91
- ai_testing_swarm/cli.py +64 -6
- ai_testing_swarm/core/config.py +22 -0
- ai_testing_swarm/core/openai_client.py +182 -0
- ai_testing_swarm/core/openapi_loader.py +130 -0
- ai_testing_swarm/core/safety.py +32 -0
- ai_testing_swarm/orchestrator.py +50 -28
- ai_testing_swarm/reporting/report_writer.py +61 -3
- ai_testing_swarm-0.1.11.dist-info/METADATA +231 -0
- ai_testing_swarm-0.1.11.dist-info/RECORD +25 -0
- ai_testing_swarm-0.1.1.dist-info/METADATA +0 -55
- ai_testing_swarm-0.1.1.dist-info/RECORD +0 -21
- {ai_testing_swarm-0.1.1.dist-info → ai_testing_swarm-0.1.11.dist-info}/WHEEL +0 -0
- {ai_testing_swarm-0.1.1.dist-info → ai_testing_swarm-0.1.11.dist-info}/entry_points.txt +0 -0
- {ai_testing_swarm-0.1.1.dist-info → ai_testing_swarm-0.1.11.dist-info}/top_level.txt +0 -0
ai_testing_swarm/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "0.1.
|
|
1
|
+
__version__ = "0.1.11"
|
|
@@ -1,64 +1,186 @@
|
|
|
1
|
-
import
|
|
1
|
+
import random
|
|
2
|
+
import time
|
|
2
3
|
from copy import deepcopy
|
|
3
4
|
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from ai_testing_swarm.core.config import (
|
|
8
|
+
AI_SWARM_RETRY_BACKOFF_MS,
|
|
9
|
+
AI_SWARM_RETRY_COUNT,
|
|
10
|
+
AI_SWARM_RPS,
|
|
11
|
+
)
|
|
12
|
+
|
|
4
13
|
|
|
5
14
|
class ExecutionAgent:
|
|
15
|
+
def _set_path(self, obj: dict, path: list[str], value, remove: bool = False):
|
|
16
|
+
"""Set/remove nested key in a dict. Creates intermediate dicts if needed."""
|
|
17
|
+
if not path:
|
|
18
|
+
return
|
|
19
|
+
cur = obj
|
|
20
|
+
for key in path[:-1]:
|
|
21
|
+
if key not in cur or not isinstance(cur[key], dict):
|
|
22
|
+
cur[key] = {}
|
|
23
|
+
cur = cur[key]
|
|
24
|
+
|
|
25
|
+
last = path[-1]
|
|
26
|
+
if remove:
|
|
27
|
+
cur.pop(last, None)
|
|
28
|
+
else:
|
|
29
|
+
cur[last] = value
|
|
30
|
+
|
|
6
31
|
def apply_mutation(self, data: dict, mutation: dict):
|
|
7
32
|
if not mutation:
|
|
8
33
|
return deepcopy(data)
|
|
9
34
|
|
|
10
35
|
data = deepcopy(data)
|
|
11
|
-
|
|
12
|
-
op = mutation
|
|
36
|
+
path = mutation.get("path") or []
|
|
37
|
+
op = mutation.get("operation")
|
|
13
38
|
|
|
14
39
|
if op == "REMOVE":
|
|
15
|
-
|
|
40
|
+
self._set_path(data, path, None, remove=True)
|
|
16
41
|
elif op == "REPLACE":
|
|
17
|
-
data
|
|
42
|
+
self._set_path(data, path, mutation.get("new_value"), remove=False)
|
|
18
43
|
|
|
19
44
|
return data
|
|
20
45
|
|
|
46
|
+
def _throttle(self):
|
|
47
|
+
# Optional global throttling to avoid hammering target services.
|
|
48
|
+
if AI_SWARM_RPS and AI_SWARM_RPS > 0:
|
|
49
|
+
time.sleep(1.0 / float(AI_SWARM_RPS))
|
|
50
|
+
|
|
51
|
+
def _should_retry(self, status_code: int | None, error: str | None) -> bool:
|
|
52
|
+
if error:
|
|
53
|
+
return True
|
|
54
|
+
if status_code in (408, 425, 429, 500, 502, 503, 504):
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
21
58
|
def execute(self, request: dict, test: dict):
|
|
22
59
|
mutation = test.get("mutation")
|
|
23
60
|
|
|
61
|
+
# Method (allow method mutations)
|
|
62
|
+
method = request["method"].upper()
|
|
63
|
+
if mutation and mutation.get("target") == "method" and mutation.get("operation") == "REPLACE":
|
|
64
|
+
method = str(mutation.get("new_value") or method).upper()
|
|
65
|
+
|
|
24
66
|
params = (
|
|
25
|
-
self.apply_mutation(request.get("params", {}), mutation)
|
|
67
|
+
self.apply_mutation(request.get("params", {}) or {}, mutation)
|
|
26
68
|
if mutation and mutation.get("target") == "query"
|
|
27
|
-
else deepcopy(request.get("params", {}))
|
|
69
|
+
else deepcopy(request.get("params", {}) or {})
|
|
28
70
|
)
|
|
29
71
|
|
|
30
|
-
body = (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
72
|
+
body = deepcopy(request.get("body", {}) or {})
|
|
73
|
+
if mutation and mutation.get("target") == "body":
|
|
74
|
+
body = self.apply_mutation(body, mutation)
|
|
75
|
+
elif mutation and mutation.get("target") == "body_whole" and mutation.get("operation") == "REPLACE":
|
|
76
|
+
body = mutation.get("new_value")
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
78
|
+
headers = deepcopy(request.get("headers", {}) or {})
|
|
79
|
+
if mutation and mutation.get("target") == "headers":
|
|
80
|
+
headers = self.apply_mutation(headers, mutation)
|
|
81
|
+
|
|
82
|
+
# Payload strategy (basic content-type aware)
|
|
83
|
+
send_json = method != "GET"
|
|
84
|
+
ct = (headers.get("content-type") or headers.get("Content-Type") or "").lower()
|
|
85
|
+
|
|
86
|
+
json_payload = None
|
|
87
|
+
data_payload = None
|
|
88
|
+
if method == "GET":
|
|
89
|
+
json_payload = None
|
|
90
|
+
data_payload = None
|
|
91
|
+
else:
|
|
92
|
+
# If body is not a dict, send raw data
|
|
93
|
+
if not isinstance(body, (dict, list)):
|
|
94
|
+
send_json = False
|
|
95
|
+
data_payload = body
|
|
96
|
+
elif "application/x-www-form-urlencoded" in ct:
|
|
97
|
+
send_json = False
|
|
98
|
+
data_payload = body
|
|
99
|
+
else:
|
|
100
|
+
json_payload = body
|
|
101
|
+
|
|
102
|
+
# Execute with light retry/backoff on transient failures.
|
|
103
|
+
attempt = 0
|
|
104
|
+
last_error = None
|
|
105
|
+
last_response = None
|
|
106
|
+
|
|
107
|
+
while True:
|
|
108
|
+
attempt += 1
|
|
109
|
+
self._throttle()
|
|
110
|
+
|
|
111
|
+
started = time.time()
|
|
112
|
+
try:
|
|
113
|
+
resp = requests.request(
|
|
114
|
+
method=method,
|
|
115
|
+
url=request["url"],
|
|
116
|
+
headers=headers,
|
|
117
|
+
params=params or None,
|
|
118
|
+
json=json_payload if send_json else None,
|
|
119
|
+
data=data_payload if not send_json else None,
|
|
120
|
+
timeout=15,
|
|
121
|
+
)
|
|
122
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
123
|
+
|
|
124
|
+
last_error = None
|
|
125
|
+
last_response = (resp, elapsed_ms)
|
|
126
|
+
|
|
127
|
+
if attempt <= (AI_SWARM_RETRY_COUNT + 1) and self._should_retry(resp.status_code, None):
|
|
128
|
+
# jittered backoff
|
|
129
|
+
backoff = (AI_SWARM_RETRY_BACKOFF_MS / 1000.0) * (2 ** (attempt - 1))
|
|
130
|
+
backoff = backoff * (0.75 + random.random() * 0.5)
|
|
131
|
+
time.sleep(min(backoff, 5.0))
|
|
132
|
+
if attempt <= AI_SWARM_RETRY_COUNT:
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
body_snippet = resp.json()
|
|
137
|
+
except Exception:
|
|
138
|
+
body_snippet = resp.text
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
"name": test["name"],
|
|
142
|
+
"mutation": mutation,
|
|
143
|
+
"request": {
|
|
144
|
+
"method": method,
|
|
145
|
+
"url": request["url"],
|
|
146
|
+
"headers": headers,
|
|
147
|
+
"params": params,
|
|
148
|
+
"body": body if method != "GET" else None,
|
|
149
|
+
},
|
|
150
|
+
"response": {
|
|
151
|
+
"status_code": resp.status_code,
|
|
152
|
+
"elapsed_ms": elapsed_ms,
|
|
153
|
+
"attempt": attempt,
|
|
154
|
+
"body_snippet": body_snippet,
|
|
155
|
+
},
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
except requests.RequestException as e:
|
|
159
|
+
elapsed_ms = int((time.time() - started) * 1000)
|
|
160
|
+
last_error = str(e)
|
|
161
|
+
last_response = None
|
|
162
|
+
|
|
163
|
+
if attempt <= AI_SWARM_RETRY_COUNT and self._should_retry(None, last_error):
|
|
164
|
+
backoff = (AI_SWARM_RETRY_BACKOFF_MS / 1000.0) * (2 ** (attempt - 1))
|
|
165
|
+
backoff = backoff * (0.75 + random.random() * 0.5)
|
|
166
|
+
time.sleep(min(backoff, 5.0))
|
|
167
|
+
continue
|
|
44
168
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
},
|
|
64
|
-
}
|
|
169
|
+
return {
|
|
170
|
+
"name": test["name"],
|
|
171
|
+
"mutation": mutation,
|
|
172
|
+
"request": {
|
|
173
|
+
"method": method,
|
|
174
|
+
"url": request["url"],
|
|
175
|
+
"headers": headers,
|
|
176
|
+
"params": params,
|
|
177
|
+
"body": body if method != "GET" else None,
|
|
178
|
+
},
|
|
179
|
+
"response": {
|
|
180
|
+
"status_code": None,
|
|
181
|
+
"elapsed_ms": elapsed_ms,
|
|
182
|
+
"attempt": attempt,
|
|
183
|
+
"error": last_error,
|
|
184
|
+
"body_snippet": None,
|
|
185
|
+
},
|
|
186
|
+
}
|
|
@@ -2,6 +2,7 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
|
|
4
4
|
DB = "memory/failure_memory.json"
|
|
5
|
+
MAX_ENTRIES = 200
|
|
5
6
|
|
|
6
7
|
|
|
7
8
|
class LearningAgent:
|
|
@@ -20,10 +21,11 @@ class LearningAgent:
|
|
|
20
21
|
# Corrupted file → reset
|
|
21
22
|
data = []
|
|
22
23
|
|
|
23
|
-
data.append({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
data.append({"test": test_name, "reason": reasoning})
|
|
25
|
+
|
|
26
|
+
# Keep file bounded
|
|
27
|
+
if len(data) > MAX_ENTRIES:
|
|
28
|
+
data = data[-MAX_ENTRIES:]
|
|
27
29
|
|
|
28
30
|
with open(DB, "w") as f:
|
|
29
31
|
json.dump(data, f, indent=2)
|