agent-supra 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.
Files changed (42) hide show
  1. agent_supra/__init__.py +27 -0
  2. agent_supra/cli.py +88 -0
  3. agent_supra/client.py +240 -0
  4. agent_supra/demo.py +202 -0
  5. agent_supra/detector.py +246 -0
  6. agent_supra/integrations.py +292 -0
  7. agent_supra/present.py +65 -0
  8. agent_supra/pricing.py +121 -0
  9. agent_supra/report.py +97 -0
  10. agent_supra/sdk.py +189 -0
  11. agent_supra/server/__init__.py +10 -0
  12. agent_supra/server/accounts.py +268 -0
  13. agent_supra/server/alerts.py +127 -0
  14. agent_supra/server/api.py +122 -0
  15. agent_supra/server/app.py +154 -0
  16. agent_supra/server/core.py +467 -0
  17. agent_supra/server/db.py +212 -0
  18. agent_supra/server/errors.py +112 -0
  19. agent_supra/server/ratelimit.py +33 -0
  20. agent_supra/server/settings.py +64 -0
  21. agent_supra/server/static/app.css +413 -0
  22. agent_supra/server/static/refresh.js +6 -0
  23. agent_supra/server/templates/auth.html +25 -0
  24. agent_supra/server/templates/base.html +57 -0
  25. agent_supra/server/templates/error.html +9 -0
  26. agent_supra/server/templates/errors.html +26 -0
  27. agent_supra/server/templates/keys.html +53 -0
  28. agent_supra/server/templates/landing.html +105 -0
  29. agent_supra/server/templates/password_reset.html +36 -0
  30. agent_supra/server/templates/privacy.html +36 -0
  31. agent_supra/server/templates/quickstart.html +69 -0
  32. agent_supra/server/templates/run.html +111 -0
  33. agent_supra/server/templates/runs.html +62 -0
  34. agent_supra/server/templates/settings.html +94 -0
  35. agent_supra/server/templates/terms.html +30 -0
  36. agent_supra/server/web.py +504 -0
  37. agent_supra-1.0.0.dist-info/METADATA +171 -0
  38. agent_supra-1.0.0.dist-info/RECORD +42 -0
  39. agent_supra-1.0.0.dist-info/WHEEL +5 -0
  40. agent_supra-1.0.0.dist-info/entry_points.txt +2 -0
  41. agent_supra-1.0.0.dist-info/licenses/LICENSE +21 -0
  42. agent_supra-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,27 @@
1
+ """Agent Supra: notice when an unattended agent is stuck, pause it, show why.
2
+
3
+ The SDK (this package's top level) has no dependencies. The server lives in
4
+ ``agent_supra.server`` and needs ``pip install 'agent-supra[server]'``.
5
+ """
6
+
7
+ __version__ = "1.0.0"
8
+
9
+ from .client import Client, SupervisorAPIError, SupervisorUnavailable # noqa: E402
10
+ from .detector import DEFAULT_POLICY, Finding, evaluate # noqa: E402
11
+ from .pricing import estimate_cost_micros, register_price # noqa: E402
12
+ from .sdk import SupervisedRun, SupervisorPaused, error_fingerprint, fingerprint # noqa: E402
13
+
14
+ __all__ = [
15
+ "Client", "SupervisedRun", "SupervisorPaused", "SupervisorAPIError", "SupervisorUnavailable",
16
+ "DEFAULT_POLICY", "Finding", "evaluate", "estimate_cost_micros", "register_price",
17
+ "error_fingerprint", "fingerprint", "Supervisor", "SupervisorError",
18
+ ]
19
+
20
+
21
+ def __getattr__(name):
22
+ # Local, in-process supervisor (needs the server extra). Imported lazily so the
23
+ # SDK stays dependency-free.
24
+ if name in ("Supervisor", "SupervisorError"):
25
+ from . import server
26
+ return getattr(server, name)
27
+ raise AttributeError(name)
agent_supra/cli.py ADDED
@@ -0,0 +1,88 @@
1
+ """Command line: ``agent-supra serve | migrate | create-user | prune | demo``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import getpass
7
+ import os
8
+ import sys
9
+
10
+
11
+ def _settings():
12
+ from .server.settings import Settings
13
+ return Settings.from_env()
14
+
15
+
16
+ def cmd_serve(args) -> int:
17
+ import uvicorn
18
+ from .server import db
19
+ db.migrate(db.make_engine(_settings().database_url)) # once, before workers start
20
+ uvicorn.run("agent_supra.server.app:create_app", factory=True, host=args.host, port=args.port,
21
+ workers=args.workers, proxy_headers=True, forwarded_allow_ips=os.environ.get("FORWARDED_ALLOW_IPS", "*"),
22
+ log_level="info")
23
+ return 0
24
+
25
+
26
+ def cmd_migrate(_args) -> int:
27
+ from .server import db
28
+ version = db.migrate(db.make_engine(_settings().database_url))
29
+ print(f"Database is at schema version {version}.")
30
+ return 0
31
+
32
+
33
+ def cmd_create_user(args) -> int:
34
+ from .server.accounts import Accounts
35
+ from .server.core import Supervisor, SupervisorError
36
+ sup = Supervisor(_settings().database_url)
37
+ password = os.environ.get("AGENT_SUPRA_NEW_PASSWORD") or getpass.getpass("Password (10+ characters): ")
38
+ try:
39
+ _, workspace = Accounts(sup.engine).signup(args.email, password, args.workspace)
40
+ except SupervisorError as exc:
41
+ print(f"Couldn't create the user: {exc}", file=sys.stderr)
42
+ return 1
43
+ print(f"Created {args.email} with workspace {workspace}.")
44
+ return 0
45
+
46
+
47
+ def cmd_prune(args) -> int:
48
+ from .server.core import Supervisor
49
+ days = args.days if args.days is not None else _settings().retention_days
50
+ removed = Supervisor(_settings().database_url).prune(days)
51
+ print(f"Removed {removed} run(s) older than {days} days.")
52
+ return 0
53
+
54
+
55
+ def main(argv: list[str] | None = None) -> int:
56
+ argv = sys.argv[1:] if argv is None else argv
57
+ if argv[:1] == ["demo"]:
58
+ # Hand everything after "demo" to the demo's own parser (--observe, --report, ...).
59
+ from .demo import main as demo_main
60
+ return demo_main(argv[1:])
61
+ parser = argparse.ArgumentParser(prog="agent-supra", description="Agent Supra server tools")
62
+ sub = parser.add_subparsers(dest="command", required=True)
63
+
64
+ serve = sub.add_parser("serve", help="run the web server (reads DATABASE_URL, BASE_URL, ...)")
65
+ serve.add_argument("--host", default=os.environ.get("HOST", "0.0.0.0"))
66
+ serve.add_argument("--port", type=int, default=int(os.environ.get("PORT", "8000")))
67
+ serve.add_argument("--workers", type=int, default=int(os.environ.get("WEB_CONCURRENCY", "2")))
68
+ serve.set_defaults(fn=cmd_serve)
69
+
70
+ sub.add_parser("migrate", help="create or upgrade database tables").set_defaults(fn=cmd_migrate)
71
+
72
+ user = sub.add_parser("create-user", help="create an account (useful with ALLOW_SIGNUP=false)")
73
+ user.add_argument("email")
74
+ user.add_argument("--workspace", help="workspace name")
75
+ user.set_defaults(fn=cmd_create_user)
76
+
77
+ prune = sub.add_parser("prune", help="delete runs older than the retention window")
78
+ prune.add_argument("--days", type=int)
79
+ prune.set_defaults(fn=cmd_prune)
80
+
81
+ sub.add_parser("demo", help="run the two-agent demo locally (try: agent-supra demo --help)")
82
+
83
+ args = parser.parse_args(argv)
84
+ return args.fn(args)
85
+
86
+
87
+ if __name__ == "__main__":
88
+ raise SystemExit(main())
agent_supra/client.py ADDED
@@ -0,0 +1,240 @@
1
+ """HTTP client for the hosted (or self-hosted) supervisor. Standard library only.
2
+
3
+ sup = Client() # reads AGENT_SUPRA_API_KEY and AGENT_SUPRA_URL
4
+
5
+ If the service can't be reached, ``fail_mode`` decides what your agent does:
6
+
7
+ * ``"open"`` (default): keep working. Events are buffered in memory and sent
8
+ when the service is reachable again; tasks and runs are created then too.
9
+ * ``"closed"``: treat it as paused (``SupervisorPaused`` is raised on the next
10
+ step). Use this when an unsupervised agent is worse than a stopped one.
11
+
12
+ Authentication or permission errors (401/403) always raise immediately: they
13
+ mean the configuration is wrong, and retrying won't fix them.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import logging
20
+ import os
21
+ import threading
22
+ import time
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ import uuid
27
+ from collections import deque
28
+ from typing import Any, Callable
29
+
30
+ from . import __version__
31
+
32
+ log = logging.getLogger("agent_supra")
33
+ DEFAULT_URL = "http://localhost:8000"
34
+ BATCH = 500
35
+
36
+
37
+ class SupervisorUnavailable(Exception):
38
+ """The service couldn't be reached (network error, timeout, 5xx, rate limit)."""
39
+
40
+
41
+ class SupervisorAPIError(Exception):
42
+ """The service rejected the request (bad key, bad input, unknown run)."""
43
+
44
+ def __init__(self, status: int, message: str):
45
+ self.status = status
46
+ super().__init__(f"{status}: {message}")
47
+
48
+
49
+ Transport = Callable[[str, str, dict | None, dict], tuple[int, dict]]
50
+
51
+
52
+ class Client:
53
+ def __init__(self, api_key: str | None = None, base_url: str | None = None, fail_mode: str = "open",
54
+ timeout: float = 5.0, retries: int = 2, max_buffer: int = 5000,
55
+ transport: Transport | None = None):
56
+ self.api_key = api_key or os.environ.get("AGENT_SUPRA_API_KEY")
57
+ if not self.api_key:
58
+ raise ValueError("No API key: pass api_key= or set AGENT_SUPRA_API_KEY")
59
+ if fail_mode not in ("open", "closed"):
60
+ raise ValueError("fail_mode must be 'open' or 'closed'")
61
+ self.base_url = (base_url or os.environ.get("AGENT_SUPRA_URL") or DEFAULT_URL).rstrip("/")
62
+ self.fail_mode = fail_mode
63
+ self.timeout = timeout
64
+ self.retries = retries
65
+ self.max_buffer = max_buffer
66
+ self._transport = transport or self._urllib
67
+ self._lock = threading.RLock()
68
+ self._tasks: dict[str, str] = {} # local id -> server id
69
+ self._pending_tasks: dict[str, dict] = {}
70
+ self._runs: dict[str, str] = {}
71
+ self._pending_runs: dict[str, tuple[str, str | None]] = {}
72
+ self._buffers: dict[str, deque] = {}
73
+ self._warned = False
74
+ self._down_until = 0.0
75
+ self.cooldown = 30.0 # after an outage, skip the network for this long
76
+ self.dropped_events = 0
77
+
78
+ # ------------------------------------------------------------------ transport
79
+ def _urllib(self, method: str, path: str, body: dict | None, params: dict) -> tuple[int, dict]:
80
+ url = self.base_url + path + (("?" + urllib.parse.urlencode(params)) if params else "")
81
+ data = json.dumps(body).encode() if body is not None else None
82
+ request = urllib.request.Request(url, data=data, method=method, headers={
83
+ "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json",
84
+ "Accept": "application/json", "User-Agent": f"agent-supra/{__version__}"})
85
+ try:
86
+ with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310
87
+ return response.status, json.loads(response.read() or b"{}")
88
+ except urllib.error.HTTPError as exc:
89
+ try:
90
+ payload = json.loads(exc.read() or b"{}")
91
+ except ValueError:
92
+ payload = {}
93
+ return exc.code, payload
94
+
95
+ def _call(self, method: str, path: str, body: dict | None = None, params: dict | None = None) -> dict:
96
+ if time.monotonic() < self._down_until:
97
+ raise SupervisorUnavailable("in cooldown after a recent outage")
98
+ delay = 0.25
99
+ last: Exception | None = None
100
+ for attempt in range(self.retries + 1):
101
+ try:
102
+ status, payload = self._transport(method, path, body, params or {})
103
+ except (OSError, ValueError) as exc: # URLError, timeouts, bad JSON
104
+ last = SupervisorUnavailable(str(exc))
105
+ else:
106
+ if status < 400:
107
+ return payload
108
+ message = payload.get("error") or payload.get("detail") or "request failed"
109
+ if status in (429, 500, 502, 503, 504):
110
+ last = SupervisorUnavailable(f"{status}: {message}")
111
+ else:
112
+ raise SupervisorAPIError(status, str(message))
113
+ if attempt < self.retries:
114
+ time.sleep(delay)
115
+ delay *= 3
116
+ self._down_until = time.monotonic() + self.cooldown
117
+ raise last or SupervisorUnavailable("unknown error")
118
+
119
+ def _degraded(self, why: Exception) -> dict:
120
+ if not self._warned:
121
+ log.warning("Agent Supra unreachable (%s); fail_mode=%s", why, self.fail_mode)
122
+ self._warned = True
123
+ if self.fail_mode == "open":
124
+ return {"allowed": True, "state": "unknown", "reason": "supervisor unreachable", "degraded": True}
125
+ return {"allowed": False, "state": "unreachable",
126
+ "reason": "supervisor unreachable and fail_mode is 'closed'", "degraded": True}
127
+
128
+ # ------------------------------------------------------------------ id resolution
129
+ def _resolve_task(self, task_id: str) -> str:
130
+ if task_id in self._pending_tasks:
131
+ real = self._call("POST", "/v1/tasks", self._pending_tasks[task_id])["task_id"]
132
+ self._tasks[task_id] = real
133
+ del self._pending_tasks[task_id]
134
+ return self._tasks.get(task_id, task_id)
135
+
136
+ def _resolve_run(self, run_id: str) -> str:
137
+ if run_id in self._pending_runs:
138
+ task_id, parent = self._pending_runs[run_id]
139
+ body = {"parent_run_id": self._resolve_run(parent) if parent else None}
140
+ real = self._call("POST", f"/v1/tasks/{self._resolve_task(task_id)}/runs", body)["run_id"]
141
+ self._runs[run_id] = real
142
+ del self._pending_runs[run_id]
143
+ return self._runs.get(run_id, run_id)
144
+
145
+ # ------------------------------------------------------------------ backend interface
146
+ def create_task(self, objective: str, budget_usd: float | None = None, max_calls: int | None = None,
147
+ policy: dict | None = None) -> str:
148
+ body = {"objective": objective, "budget_usd": budget_usd, "max_calls": max_calls, "policy": policy}
149
+ with self._lock:
150
+ try:
151
+ return self._call("POST", "/v1/tasks", body)["task_id"]
152
+ except SupervisorUnavailable as exc:
153
+ self._degraded(exc)
154
+ local = f"pending_task_{uuid.uuid4().hex[:12]}"
155
+ self._pending_tasks[local] = body
156
+ return local
157
+
158
+ def create_run(self, task_id: str, parent_run_id: str | None = None) -> str:
159
+ with self._lock:
160
+ try:
161
+ parent = self._resolve_run(parent_run_id) if parent_run_id else None
162
+ return self._call("POST", f"/v1/tasks/{self._resolve_task(task_id)}/runs",
163
+ {"parent_run_id": parent})["run_id"]
164
+ except SupervisorUnavailable as exc:
165
+ self._degraded(exc)
166
+ local = f"pending_run_{uuid.uuid4().hex[:12]}"
167
+ self._pending_runs[local] = (task_id, parent_run_id)
168
+ return local
169
+
170
+ def ingest(self, run_id: str, event: dict) -> dict:
171
+ with self._lock:
172
+ buffer = self._buffers.setdefault(run_id, deque())
173
+ buffer.append(event)
174
+ while len(buffer) > self.max_buffer:
175
+ buffer.popleft()
176
+ self.dropped_events += 1
177
+ try:
178
+ decision = self._flush(run_id)
179
+ except SupervisorUnavailable as exc:
180
+ return self._degraded(exc)
181
+ return decision if decision is not None else self.decide(run_id)
182
+
183
+ def _flush(self, run_id: str) -> dict | None:
184
+ buffer = self._buffers.get(run_id)
185
+ if not buffer:
186
+ return None
187
+ real = self._resolve_run(run_id)
188
+ decision = None
189
+ while buffer:
190
+ chunk = [buffer[i] for i in range(min(BATCH, len(buffer)))]
191
+ try:
192
+ decision = self._call("POST", f"/v1/runs/{real}/events", {"events": chunk})
193
+ except SupervisorAPIError as exc:
194
+ if exc.status in (401, 403):
195
+ raise
196
+ log.error("Agent Supra rejected %d event(s): %s", len(chunk), exc)
197
+ decision = None
198
+ for _ in chunk:
199
+ buffer.popleft()
200
+ self._warned = False
201
+ return decision
202
+
203
+ def decide(self, run_id: str, before_model_call: bool = False) -> dict:
204
+ with self._lock:
205
+ try:
206
+ self._flush(run_id)
207
+ real = self._resolve_run(run_id)
208
+ decision = self._call("GET", f"/v1/runs/{real}/decision",
209
+ params={"before_model_call": "true" if before_model_call else "false"})
210
+ self._warned = False
211
+ return decision
212
+ except SupervisorUnavailable as exc:
213
+ return self._degraded(exc)
214
+
215
+ def flush(self) -> bool:
216
+ """Try to send everything buffered. True if nothing is left."""
217
+ with self._lock:
218
+ for run_id in list(self._buffers):
219
+ try:
220
+ self._flush(run_id)
221
+ except SupervisorUnavailable:
222
+ return False
223
+ return not any(self._buffers.values())
224
+
225
+ # ------------------------------------------------------------------ reviewer actions
226
+ def pause(self, run_id: str, reason: str = "") -> dict:
227
+ return self._call("POST", f"/v1/runs/{self._resolve_run(run_id)}/pause", {"reason": reason})
228
+
229
+ def continue_run(self, run_id: str, reason: str, add_budget_usd: float = 0.0) -> dict:
230
+ return self._call("POST", f"/v1/runs/{self._resolve_run(run_id)}/continue",
231
+ {"reason": reason, "add_budget_usd": add_budget_usd})
232
+
233
+ def stop(self, run_id: str, reason: str = "") -> dict:
234
+ return self._call("POST", f"/v1/runs/{self._resolve_run(run_id)}/stop", {"reason": reason})
235
+
236
+ def timeline(self, run_id: str) -> dict[str, Any]:
237
+ return self._call("GET", f"/v1/runs/{self._resolve_run(run_id)}/timeline")
238
+
239
+ def whoami(self) -> dict:
240
+ return self._call("GET", "/v1/whoami")
agent_supra/demo.py ADDED
@@ -0,0 +1,202 @@
1
+ """Two-run demo: one agent gets stuck and is paused, one struggles but succeeds untouched.
2
+
3
+ python -m agent_supra.demo # pausing enabled for loop rules
4
+ python -m agent_supra.demo --observe # observation mode: flag only
5
+ python -m agent_supra.demo --http http://localhost:8000 # needs AGENT_SUPRA_API_KEY
6
+
7
+ Each run works on a real tiny repository in a temp folder and runs real
8
+ pytest. The "model" is scripted (FakeModel) so the demo is free and repeatable;
9
+ swap in a real client via ``run.wrap_anthropic(anthropic.Anthropic())``.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import hashlib
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ import xml.etree.ElementTree as ET
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from types import SimpleNamespace
23
+
24
+ from .pricing import usd
25
+ from .report import render_report
26
+ from .sdk import SupervisedRun, SupervisorPaused
27
+
28
+ SOURCE = '''def apply_discount(price: float, pct: float) -> float:
29
+ """Return the price after a percentage discount (pct is 0-100)."""
30
+ return {expr}
31
+ '''
32
+
33
+ TESTS = '''from shop import apply_discount
34
+
35
+
36
+ def test_no_discount():
37
+ assert apply_discount(100, 0) == 100
38
+
39
+
40
+ def test_ten_percent():
41
+ assert apply_discount(100, 10) == 90
42
+
43
+
44
+ def test_full_discount():
45
+ assert apply_discount(100, 100) == 0
46
+
47
+
48
+ def test_returns_number():
49
+ assert isinstance(apply_discount(50, 20), (int, float))
50
+ '''
51
+
52
+ BUG = "price * pct"
53
+
54
+ # Every attempt edits the code, but the same two tests keep failing.
55
+ STUCK_PATCHES = [
56
+ "price * (1 - pct)",
57
+ "price * (1 + pct / 100)",
58
+ "price + pct * 0",
59
+ "price / (1 + pct)",
60
+ "round(price * (1 - pct), 2)",
61
+ "price - pct * 0.1",
62
+ "price * (1 - pct) + 0",
63
+ "abs(price * (1 - pct))",
64
+ ]
65
+
66
+ # Fails twice, changes approach, passes.
67
+ HEALTHY_PATCHES = [
68
+ "price * (1 - pct)",
69
+ "price * (1 + pct / 100)",
70
+ "price * (100 - pct) / 100",
71
+ ]
72
+
73
+
74
+ # ---------------------------------------------------------------- the sandbox repo
75
+ def make_repo() -> Path:
76
+ repo = Path(tempfile.mkdtemp(prefix="supervised-repo-"))
77
+ (repo / "shop.py").write_text(SOURCE.format(expr=BUG))
78
+ (repo / "test_shop.py").write_text(TESTS)
79
+ return repo
80
+
81
+
82
+ def rev(repo: Path) -> str:
83
+ return hashlib.sha1((repo / "shop.py").read_bytes()).hexdigest()[:10]
84
+
85
+
86
+ def edit_file(repo: Path, expr: str) -> None:
87
+ (repo / "shop.py").write_text(SOURCE.format(expr=expr))
88
+
89
+
90
+ @dataclass
91
+ class SuiteResult:
92
+ suite: list[str]
93
+ failing: list[str]
94
+
95
+
96
+ def run_tests(repo: Path) -> SuiteResult:
97
+ report = repo / ".junit.xml"
98
+ subprocess.run([sys.executable, "-m", "pytest", "-q", "-p", "no:cacheprovider",
99
+ f"--junitxml={report}"], cwd=repo, capture_output=True, text=True, timeout=120)
100
+ suite, failing = [], []
101
+ for case in ET.parse(report).getroot().iter("testcase"):
102
+ test_id = f"{case.get('classname')}::{case.get('name')}"
103
+ suite.append(test_id)
104
+ if case.find("failure") is not None or case.find("error") is not None:
105
+ failing.append(test_id)
106
+ return SuiteResult(suite, failing)
107
+
108
+
109
+ # ---------------------------------------------------------------- a scripted "model"
110
+ class FakeModel:
111
+ """Looks like ``anthropic.Anthropic()`` to the SDK. Context grows every turn,
112
+ the way a real agent transcript does."""
113
+
114
+ def __init__(self, patches: list[str]):
115
+ self._patches = list(patches)
116
+ self._turn = 0
117
+ self.messages = self
118
+
119
+ def create(self, model: str, messages: list, max_tokens: int = 1024, **_):
120
+ patch = self._patches[min(self._turn, len(self._patches) - 1)]
121
+ usage = SimpleNamespace(input_tokens=12_000 + 9_000 * self._turn, output_tokens=800)
122
+ self._turn += 1
123
+ return SimpleNamespace(model=model, usage=usage,
124
+ content=[SimpleNamespace(type="text", text=patch)])
125
+
126
+
127
+ # ---------------------------------------------------------------- the agent loop
128
+ def run_agent(backend, task_id: str, patches: list[str], max_steps: int = 8, log=print) -> str:
129
+ repo = make_repo()
130
+ with SupervisedRun(backend, task_id) as run:
131
+ client = run.wrap_anthropic(FakeModel(patches))
132
+ result = run.tool("run_tests", run_tests, repo, rev=rev(repo))
133
+ run.report_tests(result.suite, result.failing, rev=rev(repo))
134
+ log(f" start: {len(result.failing)} failing test(s)")
135
+ try:
136
+ for step in range(1, max_steps + 1):
137
+ run.check()
138
+ reply = client.messages.create(model="demo-model", max_tokens=1024,
139
+ messages=[{"role": "user", "content": "Fix the failing tests."}])
140
+ patch = reply.content[0].text
141
+ run.tool("edit_file", edit_file, repo, patch, rev=rev(repo))
142
+ result = run.tool("run_tests", run_tests, repo, rev=rev(repo))
143
+ decision = run.report_tests(result.suite, result.failing, rev=rev(repo))
144
+ status = "all tests pass" if not result.failing else f"{len(result.failing)} failing"
145
+ log(f" attempt {step}: return {patch:<28} -> {status}")
146
+ if not result.failing:
147
+ run.finish("success")
148
+ return run.run_id
149
+ if not decision["allowed"]:
150
+ raise SupervisorPaused(decision)
151
+ run.finish("failed")
152
+ except SupervisorPaused as paused:
153
+ log(f" PAUSED by supervisor: {paused.reason}")
154
+ return run.run_id
155
+
156
+
157
+ def main(argv: list[str] | None = None) -> int:
158
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
159
+ parser.add_argument("--observe", action="store_true", help="observation mode: flag, never pause")
160
+ parser.add_argument("--db", default=":memory:", help="SQLite file (default: in memory)")
161
+ parser.add_argument("--http", help="send events to a running supervisor API instead")
162
+ parser.add_argument("--report", default="supervisor_report.html", help="where to write the HTML report")
163
+ args = parser.parse_args(argv)
164
+ try:
165
+ import pytest # noqa: F401 (the sample repo's tests run under pytest)
166
+ except ImportError:
167
+ print("The demo runs real tests and needs pytest: pip install pytest")
168
+ return 1
169
+
170
+ policy = {"observation_mode": args.observe}
171
+ if args.http:
172
+ from .client import Client
173
+ backend, sup = Client(base_url=args.http), None
174
+ else:
175
+ from .server import Supervisor
176
+ backend = sup = Supervisor("sqlite://" if args.db == ":memory:" else f"sqlite:///{args.db}")
177
+
178
+ mode = "observation mode (flag only)" if args.observe else "pausing enabled"
179
+ print(f"Agent Supra demo · {mode}\n")
180
+ print("Run A: an agent that keeps editing code while the same tests fail")
181
+ task_a = backend.create_task("Run A: stuck agent fixing apply_discount", budget_usd=5.0, policy=policy)
182
+ run_a = run_agent(backend, task_a, STUCK_PATCHES)
183
+ print("\nRun B: an agent that fails twice, changes approach, and succeeds")
184
+ task_b = backend.create_task("Run B: healthy agent fixing apply_discount", budget_usd=5.0, policy=policy)
185
+ run_b = run_agent(backend, task_b, HEALTHY_PATCHES)
186
+
187
+ if sup is not None:
188
+ print()
189
+ for run_id in (run_a, run_b):
190
+ tl = sup.timeline(run_id)
191
+ flags = [i for i in tl["interventions"] if i["actor"] == "supervisor"]
192
+ print(f"{tl['task']['objective'].split(':')[0]}: {tl['run']['state']}, "
193
+ f"estimated spend {usd(tl['run_spend_micros'])}, "
194
+ f"{len(flags)} supervisor intervention(s)"
195
+ + "".join(f"\n - {f['action']} [{f['rule']}] {f['message']}" for f in flags))
196
+ Path(args.report).write_text(render_report(sup, [run_a, run_b]))
197
+ print(f"\nTimeline report written to {args.report}")
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())