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.
- agent_test_loop/__init__.py +3 -0
- agent_test_loop/cli.py +13 -0
- agent_test_loop/runner.py +266 -0
- agent_test_loop-1.0.0.dist-info/METADATA +85 -0
- agent_test_loop-1.0.0.dist-info/RECORD +9 -0
- agent_test_loop-1.0.0.dist-info/WHEEL +5 -0
- agent_test_loop-1.0.0.dist-info/entry_points.txt +2 -0
- agent_test_loop-1.0.0.dist-info/licenses/LICENSE +21 -0
- agent_test_loop-1.0.0.dist-info/top_level.txt +1 -0
agent_test_loop/cli.py
ADDED
|
@@ -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,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
|