agent-test-loop 1.0.0__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.
@@ -0,0 +1,3 @@
1
+ """Deterministic critic for AI agents — score 0-100 and block delivery below threshold."""
2
+
3
+ __version__ = "1.0.0"
agent_test_loop/cli.py ADDED
@@ -0,0 +1,13 @@
1
+ """Console entry point: `agent-test-loop --config loop.json`."""
2
+ import sys
3
+
4
+ from . import runner
5
+
6
+
7
+ def main() -> int:
8
+ sys.argv[0] = "agent-test-loop"
9
+ return runner.main()
10
+
11
+
12
+ if __name__ == "__main__":
13
+ raise SystemExit(main())
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env python3
2
+ """loop_runner — the deterministic critic of the AI Test Loop.
3
+
4
+ Runs a list of weighed checks for real, captures their raw output as evidence, and prints:
5
+
6
+ SCORE=<0-100>
7
+ GAPS=["<check>: <why it failed>", ...]
8
+ EVIDENCE=<path to the JSON report>
9
+
10
+ The agent (builder) is not allowed to grade its own work; this script measures. It is
11
+ deliberately dumb: exit codes + optional output patterns decide pass/fail. No model calls,
12
+ no network, no telemetry.
13
+
14
+ Config (JSON):
15
+
16
+ {
17
+ "name": "my-project",
18
+ "threshold": 85,
19
+ "max_rounds": 8,
20
+ "checks": [
21
+ {"name": "unit tests", "cmd": "pytest -q", "weight": 40, "timeout_s": 900},
22
+ {"name": "build", "cmd": "npm run build", "weight": 25, "timeout_s": 600},
23
+ {"name": "lint", "cmd": "ruff check .", "weight": 10},
24
+ {"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 15,
25
+ "fail_if_output_matches": "leaks found: [1-9]"},
26
+ {"name": "api responds", "cmd": "curl -sS localhost:8000/health", "weight": 10,
27
+ "must_match": "\"status\"\\s*:\\s*\"ok\""}
28
+ ]
29
+ }
30
+
31
+ Check keys:
32
+ cmd (required) shell command; runs with pipefail semantics via bash -o pipefail -c
33
+ weight (required) points; all weights are normalised, so they need not sum to 100
34
+ timeout_s (optional, default 900) hard timeout — a timeout is a FAIL, never a skip
35
+ expect_exit (optional, default 0) exit code that means pass
36
+ must_match (optional) regex that MUST appear in combined output
37
+ fail_if_output_matches (optional) regex that fails the check when it appears
38
+ required (optional, default true) when false, a FAIL scores 0 but is reported as a
39
+ non-blocking gap (useful for "nice to have" checks)
40
+ cwd (optional) working directory for this check
41
+
42
+ Usage:
43
+ python loop_runner.py --config loop.json [--round 2] [--json] [--quiet]
44
+ python loop_runner.py --config loop.json --show-state # print round history and stop
45
+ python loop_runner.py --config loop.json --dry-run # list checks, run nothing
46
+
47
+ Exit code: 0 when SCORE >= threshold (gate passed), 1 when below, 2 on usage/config error.
48
+
49
+ State: <config dir>/.<config-stem>-state.json accumulates every round, so a crash or a restart
50
+ never loses the evidence trail. The report of the latest round is written to
51
+ <config dir>/<config-stem>-report-r<round>.json. Names are derived from the config file so two
52
+ projects in the same folder never share a history.
53
+ """
54
+ from __future__ import annotations
55
+
56
+ import argparse
57
+ import json
58
+ import os
59
+ import pathlib
60
+ import re
61
+ import shlex
62
+ import subprocess
63
+ import sys
64
+ import time
65
+ from datetime import datetime, timezone
66
+
67
+ DEFAULT_TIMEOUT = 900
68
+ MAX_OUTPUT_CHARS = 8000
69
+
70
+
71
+ def load_config(path: str) -> dict:
72
+ try:
73
+ with open(path, "r", encoding="utf-8") as fh:
74
+ cfg = json.load(fh)
75
+ except FileNotFoundError:
76
+ sys.exit(f"[loop_runner] config not found: {path}")
77
+ except json.JSONDecodeError as exc:
78
+ sys.exit(f"[loop_runner] config is not valid JSON: {exc}")
79
+ checks = cfg.get("checks")
80
+ if not isinstance(checks, list) or not checks:
81
+ sys.exit("[loop_runner] config needs a non-empty 'checks' list")
82
+ for i, chk in enumerate(checks):
83
+ if not chk.get("cmd"):
84
+ sys.exit(f"[loop_runner] check #{i} has no 'cmd'")
85
+ if not isinstance(chk.get("weight", None), (int, float)) or chk["weight"] <= 0:
86
+ sys.exit(f"[loop_runner] check {chk.get('name', i)!r} needs a positive 'weight'")
87
+ cfg.setdefault("name", os.path.basename(os.path.dirname(os.path.abspath(path))) or "loop")
88
+ cfg.setdefault("threshold", 85)
89
+ cfg.setdefault("max_rounds", 8)
90
+ return cfg
91
+
92
+
93
+ def run_check(chk: dict) -> dict:
94
+ """Run one check for real, return its result record with raw evidence."""
95
+ name = chk.get("name") or chk["cmd"][:40]
96
+ timeout = int(chk.get("timeout_s", DEFAULT_TIMEOUT))
97
+ cwd = chk.get("cwd") or None
98
+ expect_exit = int(chk.get("expect_exit", 0))
99
+ # bash -o pipefail: without it, `pytest | tail` returns tail's exit code (0) and a failing
100
+ # check scores as passing — the single most common false green in this kind of runner.
101
+ wrapped = ["bash", "-o", "pipefail", "-c", chk["cmd"]]
102
+ started = time.time()
103
+ timed_out = False
104
+ try:
105
+ proc = subprocess.run(
106
+ wrapped, cwd=cwd, capture_output=True, text=True, timeout=timeout,
107
+ stdin=subprocess.DEVNULL,
108
+ )
109
+ rc, out, err = proc.returncode, proc.stdout or "", proc.stderr or ""
110
+ except subprocess.TimeoutExpired as exc:
111
+ timed_out = True
112
+ rc = None
113
+ out = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
114
+ err = (exc.stderr or b"").decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
115
+ duration = round(time.time() - started, 2)
116
+ combined = (out or "") + ("\n" + err if err else "")
117
+
118
+ reasons: list[str] = []
119
+ if timed_out:
120
+ reasons.append(f"timed out after {timeout}s")
121
+ elif rc != expect_exit:
122
+ reasons.append(f"exit code {rc}, expected {expect_exit}")
123
+
124
+ must = chk.get("must_match")
125
+ if must and not re.search(must, combined):
126
+ reasons.append(f"output does not match required pattern /{must}/")
127
+
128
+ fail_pat = chk.get("fail_if_output_matches")
129
+ if fail_pat:
130
+ hit = re.search(fail_pat, combined)
131
+ if hit:
132
+ reasons.append(f"output matched failure pattern /{fail_pat}/: {hit.group(0)!r}")
133
+
134
+ passed = not reasons
135
+ evidence = combined.strip()
136
+ if len(evidence) > MAX_OUTPUT_CHARS:
137
+ evidence = evidence[: MAX_OUTPUT_CHARS // 2] + "\n...[truncated]...\n" + evidence[-MAX_OUTPUT_CHARS // 2:]
138
+ return {
139
+ "name": name,
140
+ "cmd": chk["cmd"],
141
+ "weight": float(chk["weight"]),
142
+ "required": bool(chk.get("required", True)),
143
+ "passed": passed,
144
+ "reasons": reasons,
145
+ "exit_code": rc,
146
+ "duration_s": duration,
147
+ "evidence": evidence,
148
+ }
149
+
150
+
151
+ def score(checks: list[dict]) -> float:
152
+ total = sum(c["weight"] for c in checks) or 1.0
153
+ earned = sum(c["weight"] for c in checks if c["passed"])
154
+ return round(earned / total * 100, 1)
155
+
156
+
157
+ def main() -> int:
158
+ ap = argparse.ArgumentParser(add_help=True, description="Deterministic critic for the AI Test Loop")
159
+ ap.add_argument("--config", required=True)
160
+ ap.add_argument("--round", type=int)
161
+ ap.add_argument("--json", action="store_true", help="also dump the full report as JSON to stdout")
162
+ ap.add_argument("--quiet", action="store_true", help="only SCORE/GAPS/EVIDENCE lines")
163
+ ap.add_argument("--dry-run", action="store_true")
164
+ ap.add_argument("--show-state", action="store_true")
165
+ args = ap.parse_args()
166
+
167
+ cfg_path = os.path.abspath(args.config)
168
+ cfg = load_config(cfg_path)
169
+ base = os.path.dirname(cfg_path)
170
+ # Per-config filenames: two configs in the same folder must not share one state file,
171
+ # otherwise rounds from project A pollute project B's history and round numbers.
172
+ stem = os.path.splitext(os.path.basename(cfg_path))[0]
173
+ state_path = os.path.join(base, f".{stem}-state.json")
174
+
175
+ if args.show_state:
176
+ if not os.path.exists(state_path):
177
+ print("[loop_runner] no state file yet")
178
+ return 0
179
+ with open(state_path, "r", encoding="utf-8") as fh:
180
+ st = json.load(fh)
181
+ for r in st.get("rounds", []):
182
+ print(f"round {r['round']}: SCORE={r['score']} threshold={r['threshold']} "
183
+ f"failed={[c['name'] for c in r['checks'] if not c['passed']]}")
184
+ return 0
185
+
186
+ if args.dry_run:
187
+ for c in cfg["checks"]:
188
+ print(f"[dry-run] {c.get('name', c['cmd'][:40])} weight={c['weight']} cmd={c['cmd']}")
189
+ print(f"[dry-run] threshold={cfg['threshold']} max_rounds={cfg['max_rounds']}")
190
+ return 0
191
+
192
+ round_no = args.round
193
+ if round_no is None:
194
+ prev = 0
195
+ if os.path.exists(state_path):
196
+ with open(state_path, "r", encoding="utf-8") as fh:
197
+ prev = len(json.load(fh).get("rounds", []))
198
+ round_no = prev + 1
199
+
200
+ if round_no > int(cfg["max_rounds"]):
201
+ print(f"ROUND: {round_no} ABORT: exceeded max_rounds={cfg['max_rounds']}")
202
+ print("GAPS=[\"round budget exhausted — the check set or the threshold needs revisiting\"]")
203
+ return 1
204
+
205
+ results = []
206
+ for chk in cfg["checks"]:
207
+ if not args.quiet:
208
+ print(f"[loop_runner] round {round_no}: running {chk.get('name', chk['cmd'][:40])!r} ...",
209
+ file=sys.stderr, flush=True)
210
+ results.append(run_check(chk))
211
+
212
+ value = score(results)
213
+ threshold = float(cfg["threshold"])
214
+ gate = value >= threshold
215
+ gaps = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and c["required"]]
216
+ soft = [f"{c['name']}: {'; '.join(c['reasons'])}" for c in results if not c["passed"] and not c["required"]]
217
+
218
+ report = {
219
+ "project": cfg["name"],
220
+ "round": round_no,
221
+ "ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
222
+ "threshold": threshold,
223
+ "score": value,
224
+ "gate": "passed" if gate else "failed",
225
+ "checks": results,
226
+ "gaps": gaps,
227
+ "non_blocking_gaps": soft,
228
+ }
229
+ report_path = os.path.join(base, f"{stem}-report-r{round_no}.json")
230
+ pathlib.Path(report_path).write_text(
231
+ json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
232
+
233
+ state = {"project": cfg["name"], "rounds": []}
234
+ if os.path.exists(state_path):
235
+ try:
236
+ with open(state_path, "r", encoding="utf-8") as fh:
237
+ state = json.load(fh)
238
+ state.setdefault("rounds", [])
239
+ except json.JSONDecodeError:
240
+ pass
241
+ state["rounds"].append({"round": round_no, "score": value, "threshold": threshold,
242
+ "gate": report["gate"], "ts": report["ts"], "report": report_path,
243
+ "checks": results})
244
+ pathlib.Path(state_path).write_text(
245
+ json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
246
+
247
+ if not args.quiet:
248
+ for c in results:
249
+ status = "PASS" if c["passed"] else "FAIL"
250
+ detail = c["evidence"].strip().splitlines()[-1][:160] if c["evidence"].strip() else "(no output)"
251
+ print(f" {status} {c['name']:<28} w={c['weight']:<5} {c['duration_s']:>7}s {detail}")
252
+
253
+ print(f"SCORE={value}")
254
+ print(f"THRESHOLD={threshold}")
255
+ print(f"GATE={'passed' if gate else 'failed'} (round {round_no}/{cfg['max_rounds']})")
256
+ print(f"GAPS={json.dumps(gaps, ensure_ascii=False)}")
257
+ if soft:
258
+ print(f"NON_BLOCKING_GAPS={json.dumps(soft, ensure_ascii=False)}")
259
+ print(f"EVIDENCE={report_path}")
260
+ if args.json:
261
+ print(json.dumps(report, ensure_ascii=False, indent=2))
262
+ return 0 if gate else 1
263
+
264
+
265
+ if __name__ == "__main__":
266
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-test-loop
3
+ Version: 1.0.0
4
+ Summary: Deterministic critic for AI agents: runs weighed checks for real, scores 0-100 and blocks delivery below the threshold.
5
+ Author: Samuel Medeiros
6
+ License: MIT
7
+ Project-URL: Homepage, https://skills.seu.pet/en/p/ai-test-loop?utm_source=pypi&utm_medium=readme&utm_campaign=ai-test-loop
8
+ Project-URL: Repository, https://github.com/Samuelfmedeiros/ai-test-loop
9
+ Keywords: ai-agents,test-loop,quality-gate,critic,llm
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Provides-Extra: dev
17
+ Requires-Dist: build; extra == "dev"
18
+ Requires-Dist: twine; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # agent-test-loop
22
+
23
+ Your agent says "done". This makes it prove it.
24
+
25
+ `agent-test-loop` is a **deterministic critic**: it runs the checks you declare (tests, build, lint,
26
+ a health endpoint, a secrets scan), captures the real output as evidence, and prints a score from
27
+ 0 to 100. Below the threshold, delivery is blocked. No model calls, no network, no telemetry —
28
+ stdlib only.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pip install agent-test-loop
34
+ ```
35
+
36
+ ## Use
37
+
38
+ ```bash
39
+ agent-test-loop --config loop.json
40
+ ```
41
+
42
+ `loop.json`:
43
+
44
+ ```json
45
+ {
46
+ "name": "my-project",
47
+ "threshold": 85,
48
+ "max_rounds": 8,
49
+ "checks": [
50
+ {"name": "unit tests", "cmd": "pytest -q", "weight": 40, "timeout_s": 900},
51
+ {"name": "build", "cmd": "npm run build", "weight": 25, "timeout_s": 600},
52
+ {"name": "lint", "cmd": "ruff check .", "weight": 10},
53
+ {"name": "no secrets", "cmd": "gitleaks detect --no-banner", "weight": 15,
54
+ "fail_if_output_matches": "leaks found: [1-9]"},
55
+ {"name": "api responds", "cmd": "curl -sS localhost:8000/health", "weight": 10,
56
+ "must_match": "\"status\"\\s*:\\s*\"ok\""}
57
+ ]
58
+ }
59
+ ```
60
+
61
+ Output:
62
+
63
+ ```
64
+ SCORE=100
65
+ GAPS=[]
66
+ EVIDENCE=.loop/round-1.json
67
+ ```
68
+
69
+ Useful flags: `--dry-run` (validate the config without running), `--quiet` (only the three lines
70
+ above), `--json` (full report), `--show-state` (last round from `.loop/`).
71
+
72
+ ## What it does not do
73
+
74
+ It does not judge code quality, style, or intent — it measures whether the commands you declared
75
+ actually pass. That is the point: the agent that wrote the code is not allowed to grade itself.
76
+
77
+ ## Need the full gate?
78
+
79
+ Visual diff that does not produce false positives, MCP handshake preflight, GPU training guard and
80
+ the delivery-proof ebook:
81
+
82
+ - Full pack — https://skills.seu.pet/en/p/pack-completo?utm_source=pypi&utm_medium=readme&utm_campaign=agent-test-loop
83
+ - Guided setup, first cycle run together — https://skills.seu.pet/en/service?utm_source=pypi&utm_medium=readme&utm_campaign=agent-test-loop
84
+
85
+ MIT licensed.
@@ -0,0 +1,9 @@
1
+ agent_test_loop/__init__.py,sha256=1W6NHYbYSbs8ijOixbwpdouIXBtGWVkLbBy43i9-B94,116
2
+ agent_test_loop/cli.py,sha256=TKMnHm9z8N8L6M94dVFpJiwi0zlDbhvC_LeZfP_YkE4,238
3
+ agent_test_loop/runner.py,sha256=lEbvxk6xnrCT9XYz8UehKImqg_abqhAy_QLnifYrJkE,11137
4
+ agent_test_loop-1.0.0.dist-info/licenses/LICENSE,sha256=wRooGlQmxnT_ULZwshgiibjk5TQpOtzmpTcx7kHmMt0,1072
5
+ agent_test_loop-1.0.0.dist-info/METADATA,sha256=YvbDa5-vZ_BmM3utkU0JAkrFv1NXcKiBfFfdDoynZFc,2790
6
+ agent_test_loop-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ agent_test_loop-1.0.0.dist-info/entry_points.txt,sha256=MysC_P3-wDXwUxkLTc8dE3DH40t7w-RTLhfQAIjbaW8,61
8
+ agent_test_loop-1.0.0.dist-info/top_level.txt,sha256=P9CXoGthuJpUYuWGqOLwlLUiGSPGP0KkpDu7yshT8f4,16
9
+ agent_test_loop-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ agent-test-loop = agent_test_loop.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel Medeiros
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ agent_test_loop