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.
- agent_supra/__init__.py +27 -0
- agent_supra/cli.py +88 -0
- agent_supra/client.py +240 -0
- agent_supra/demo.py +202 -0
- agent_supra/detector.py +246 -0
- agent_supra/integrations.py +292 -0
- agent_supra/present.py +65 -0
- agent_supra/pricing.py +121 -0
- agent_supra/report.py +97 -0
- agent_supra/sdk.py +189 -0
- agent_supra/server/__init__.py +10 -0
- agent_supra/server/accounts.py +268 -0
- agent_supra/server/alerts.py +127 -0
- agent_supra/server/api.py +122 -0
- agent_supra/server/app.py +154 -0
- agent_supra/server/core.py +467 -0
- agent_supra/server/db.py +212 -0
- agent_supra/server/errors.py +112 -0
- agent_supra/server/ratelimit.py +33 -0
- agent_supra/server/settings.py +64 -0
- agent_supra/server/static/app.css +413 -0
- agent_supra/server/static/refresh.js +6 -0
- agent_supra/server/templates/auth.html +25 -0
- agent_supra/server/templates/base.html +57 -0
- agent_supra/server/templates/error.html +9 -0
- agent_supra/server/templates/errors.html +26 -0
- agent_supra/server/templates/keys.html +53 -0
- agent_supra/server/templates/landing.html +105 -0
- agent_supra/server/templates/password_reset.html +36 -0
- agent_supra/server/templates/privacy.html +36 -0
- agent_supra/server/templates/quickstart.html +69 -0
- agent_supra/server/templates/run.html +111 -0
- agent_supra/server/templates/runs.html +62 -0
- agent_supra/server/templates/settings.html +94 -0
- agent_supra/server/templates/terms.html +30 -0
- agent_supra/server/web.py +504 -0
- agent_supra-1.0.0.dist-info/METADATA +171 -0
- agent_supra-1.0.0.dist-info/RECORD +42 -0
- agent_supra-1.0.0.dist-info/WHEEL +5 -0
- agent_supra-1.0.0.dist-info/entry_points.txt +2 -0
- agent_supra-1.0.0.dist-info/licenses/LICENSE +21 -0
- agent_supra-1.0.0.dist-info/top_level.txt +1 -0
agent_supra/__init__.py
ADDED
|
@@ -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())
|