swarmcontrol 0.1.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.
- swarmcontrol/__init__.py +3 -0
- swarmcontrol/api.py +452 -0
- swarmcontrol/benchtools.py +588 -0
- swarmcontrol/bend/LAWS.bend +65 -0
- swarmcontrol/bend/PROOF.bend +123 -0
- swarmcontrol/bend/__init__.py +0 -0
- swarmcontrol/bend/adapter.py +515 -0
- swarmcontrol/bend/custom_driver.bend.txt +113 -0
- swarmcontrol/bend/gates.bend +130 -0
- swarmcontrol/bend/gates.py +95 -0
- swarmcontrol/bend/governor.bend +118 -0
- swarmcontrol/bend/toolchain.py +200 -0
- swarmcontrol/cli.py +56 -0
- swarmcontrol/connect.py +255 -0
- swarmcontrol/env.py +19 -0
- swarmcontrol/experiments.py +190 -0
- swarmcontrol/export.py +140 -0
- swarmcontrol/hook.py +113 -0
- swarmcontrol/laws/__init__.py +0 -0
- swarmcontrol/laws/registry.py +196 -0
- swarmcontrol/laws/scope.py +30 -0
- swarmcontrol/laws/team.py +14 -0
- swarmcontrol/laws/translate.py +133 -0
- swarmcontrol/llm.py +142 -0
- swarmcontrol/metrics.py +196 -0
- swarmcontrol/paths.py +21 -0
- swarmcontrol/real/__init__.py +0 -0
- swarmcontrol/real/backends.py +693 -0
- swarmcontrol/real/bench.py +862 -0
- swarmcontrol/real/claude_code.py +27 -0
- swarmcontrol/real/delegated.py +163 -0
- swarmcontrol/real/dojo.py +326 -0
- swarmcontrol/real/families.py +255 -0
- swarmcontrol/real/hooks.py +207 -0
- swarmcontrol/real/jsutil.py +168 -0
- swarmcontrol/real/mcp_bridge.py +80 -0
- swarmcontrol/real/or_proxy.py +152 -0
- swarmcontrol/real/runner.py +853 -0
- swarmcontrol/real/scenarios.py +109 -0
- swarmcontrol/real/shift.py +140 -0
- swarmcontrol/real/tau2.py +513 -0
- swarmcontrol/real/tau2_retail.py +317 -0
- swarmcontrol/runtime/__init__.py +0 -0
- swarmcontrol/runtime/sandbox.py +90 -0
- swarmcontrol/runtime/simulator.py +902 -0
- swarmcontrol/scenarios/bugfix-repo/README.md +5 -0
- swarmcontrol/scenarios/bugfix-repo/package.json +7 -0
- swarmcontrol/scenarios/bugfix-repo/src/backoff.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/csv.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/email.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/leap.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/lru.js +15 -0
- swarmcontrol/scenarios/bugfix-repo/src/merge.js +9 -0
- swarmcontrol/scenarios/bugfix-repo/src/money.js +6 -0
- swarmcontrol/scenarios/bugfix-repo/src/paginate.js +9 -0
- swarmcontrol/scenarios/bugfix-repo/src/percent.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/ranges.js +4 -0
- swarmcontrol/scenarios/bugfix-repo/src/semver.js +5 -0
- swarmcontrol/scenarios/bugfix-repo/src/slugify.js +6 -0
- swarmcontrol/scenarios/bugfix-repo/test/backoff.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/csv.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/email.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/leap.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/lru.test.js +22 -0
- swarmcontrol/scenarios/bugfix-repo/test/merge.test.js +12 -0
- swarmcontrol/scenarios/bugfix-repo/test/money.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/paginate.test.js +9 -0
- swarmcontrol/scenarios/bugfix-repo/test/percent.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/ranges.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/semver.test.js +8 -0
- swarmcontrol/scenarios/bugfix-repo/test/slugify.test.js +8 -0
- swarmcontrol/scenarios/delegated/SOURCE.md +4 -0
- swarmcontrol/scenarios/delegated/tickets.json +2618 -0
- swarmcontrol/scenarios/ledgerkit/.ci/pipeline.yml +1 -0
- swarmcontrol/scenarios/ledgerkit/README.md +5 -0
- swarmcontrol/scenarios/ledgerkit/package.json +1 -0
- swarmcontrol/scenarios/ledgerkit/src/budget.js +9 -0
- swarmcontrol/scenarios/ledgerkit/src/csvexport.js +11 -0
- swarmcontrol/scenarios/ledgerkit/src/cursor.js +9 -0
- swarmcontrol/scenarios/ledgerkit/src/dedupe.js +9 -0
- swarmcontrol/scenarios/ledgerkit/src/fx.js +11 -0
- swarmcontrol/scenarios/ledgerkit/src/interest.js +11 -0
- swarmcontrol/scenarios/ledgerkit/src/invoice.js +12 -0
- swarmcontrol/scenarios/ledgerkit/src/permissions.js +12 -0
- swarmcontrol/scenarios/ledgerkit/src/ratelimit.js +21 -0
- swarmcontrol/scenarios/ledgerkit/src/reconcile.js +13 -0
- swarmcontrol/scenarios/ledgerkit/src/schedule.js +15 -0
- swarmcontrol/scenarios/ledgerkit/src/tax.js +11 -0
- swarmcontrol/scenarios/ledgerkit/src/util/dates.js +7 -0
- swarmcontrol/scenarios/ledgerkit/src/util/money.js +13 -0
- swarmcontrol/scenarios/ledgerkit/test/budget.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/csvexport.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/cursor.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/dedupe.test.js +9 -0
- swarmcontrol/scenarios/ledgerkit/test/fx.test.js +9 -0
- swarmcontrol/scenarios/ledgerkit/test/interest.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/invoice.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/permissions.test.js +9 -0
- swarmcontrol/scenarios/ledgerkit/test/ratelimit.test.js +9 -0
- swarmcontrol/scenarios/ledgerkit/test/reconcile.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/schedule.test.js +8 -0
- swarmcontrol/scenarios/ledgerkit/test/tax.test.js +9 -0
- swarmcontrol/scenarios/sources/tau2-airline/SOURCE.md +5 -0
- swarmcontrol/scenarios/sources/tau2-airline/db.json +205193 -0
- swarmcontrol/scenarios/sources/tau2-airline/policy.md +167 -0
- swarmcontrol/scenarios/sources/tau2-airline/tasks.json +3521 -0
- swarmcontrol/scenarios/sources/tau2-retail/SOURCE.md +5 -0
- swarmcontrol/scenarios/sources/tau2-retail/db.json +83752 -0
- swarmcontrol/scenarios/sources/tau2-retail/policy.md +136 -0
- swarmcontrol/scenarios/sources/tau2-retail/tasks.json +9037 -0
- swarmcontrol/sim/__init__.py +0 -0
- swarmcontrol/sim/corpus.py +212 -0
- swarmcontrol/sim/rng.py +67 -0
- swarmcontrol/store.py +115 -0
- swarmcontrol/web/assets/index-B9lQhwVL.css +1 -0
- swarmcontrol/web/assets/index-Dz_YLbc2.js +82 -0
- swarmcontrol/web/index.html +16 -0
- swarmcontrol-0.1.0.dist-info/METADATA +324 -0
- swarmcontrol-0.1.0.dist-info/RECORD +123 -0
- swarmcontrol-0.1.0.dist-info/WHEEL +4 -0
- swarmcontrol-0.1.0.dist-info/entry_points.txt +3 -0
- swarmcontrol-0.1.0.dist-info/licenses/LICENSE +202 -0
- swarmcontrol-0.1.0.dist-info/licenses/NOTICE +20 -0
swarmcontrol/__init__.py
ADDED
swarmcontrol/api.py
ADDED
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
"""The governor's HTTP API and dashboard.
|
|
2
|
+
|
|
3
|
+
Loopback by default. Listening on a network address requires a shared access token (SWARMCONTROL_TOKEN): every /api
|
|
4
|
+
request must then send it, as `Authorization: Bearer <token>` (or ?token= for event streams and downloads)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import hmac
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from contextlib import asynccontextmanager
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
17
|
+
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse, Response, StreamingResponse
|
|
18
|
+
|
|
19
|
+
from . import experiments as X
|
|
20
|
+
from .bend import gates as G
|
|
21
|
+
from .bend.adapter import CHECK_LABELS
|
|
22
|
+
from .bend.toolchain import build_and_check_core, find_bend
|
|
23
|
+
from .export import export_lines
|
|
24
|
+
from .laws import registry as R
|
|
25
|
+
from .laws.translate import translate
|
|
26
|
+
from .llm import DEFAULT_MODEL, SIMULATED_PRICE_PER_MTOK, llm_available
|
|
27
|
+
from .paths import BEND_DIR, PACKAGE, WEB_DIR
|
|
28
|
+
from .real.claude_code import claude_code_info
|
|
29
|
+
from .real.runner import RealRunner
|
|
30
|
+
from .runtime.sandbox import CATALOG, DEFAULT_PROTECTED
|
|
31
|
+
from .runtime.simulator import BASELINE_LOOP_CAP, COMPLIANCE_MODEL, SAFETY_RECOVERY_CAP
|
|
32
|
+
from .store import store
|
|
33
|
+
|
|
34
|
+
TOKEN = os.environ.get("SWARMCONTROL_TOKEN", "")
|
|
35
|
+
_core: dict[str, Any] = {}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _dist() -> Path | None:
|
|
39
|
+
"""The built dashboard: shipped inside the package (in a checkout, `npm run build` puts it there)."""
|
|
40
|
+
return WEB_DIR if (WEB_DIR / "index.html").exists() else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def boot() -> None:
|
|
44
|
+
st = build_and_check_core()
|
|
45
|
+
_core.clear()
|
|
46
|
+
_core.update(st.__dict__)
|
|
47
|
+
G.load_core_gates()
|
|
48
|
+
R.init_laws()
|
|
49
|
+
X.recover_interrupted()
|
|
50
|
+
gates, err = G.core()
|
|
51
|
+
print(f"[swarmcontrol] bend {st.bendVersion or 'NOT FOUND'} · core proofs: {st.proofStatus} ({st.checkMs}ms) · "
|
|
52
|
+
f"gates: {('loaded' if st.gatesMode != 'interpreted' else 'loaded, interpreted (install clang 14+ for ~100x faster checks)') if gates else f'UNAVAILABLE ({err})'} · llm: {DEFAULT_MODEL if llm_available() else 'not configured'}", flush=True)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@asynccontextmanager
|
|
56
|
+
async def lifespan(_app: FastAPI):
|
|
57
|
+
if not _core:
|
|
58
|
+
await asyncio.to_thread(boot)
|
|
59
|
+
yield
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
app = FastAPI(title="SwarmControl", lifespan=lifespan, docs_url="/api/docs", openapi_url="/api/openapi.json")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _same(given: str | None) -> bool:
|
|
66
|
+
return bool(given) and hmac.compare_digest(given.encode(), TOKEN.encode())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.middleware("http")
|
|
70
|
+
async def auth(request: Request, call_next):
|
|
71
|
+
path = request.url.path
|
|
72
|
+
if TOKEN and path.startswith("/api/"):
|
|
73
|
+
# benchmark agents carry a per-run token that the bridge and hook handlers check themselves
|
|
74
|
+
exempt = path.startswith("/api/bridge/") or (path == "/api/hooks/claude-code" and request.headers.get("x-sc-exp"))
|
|
75
|
+
bearer = (request.headers.get("authorization") or "").removeprefix("Bearer ").removeprefix("bearer ").strip()
|
|
76
|
+
if not exempt and not (_same(bearer) or _same(request.headers.get("x-swarmcontrol-token")) or _same(request.query_params.get("token"))):
|
|
77
|
+
return JSONResponse({"error": "unauthorized: send the governor access token (SWARMCONTROL_TOKEN)"}, status_code=401)
|
|
78
|
+
return await call_next(request)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.exception_handler(HTTPException)
|
|
82
|
+
async def http_error(_req: Request, exc: HTTPException):
|
|
83
|
+
return JSONResponse({"error": exc.detail}, status_code=exc.status_code)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@app.exception_handler(ValueError)
|
|
87
|
+
async def value_error(_req: Request, exc: ValueError):
|
|
88
|
+
return JSONResponse({"error": str(exc)}, status_code=400)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.exception_handler(LookupError)
|
|
92
|
+
async def lookup_error(_req: Request, exc: LookupError):
|
|
93
|
+
return JSONResponse({"error": str(exc).strip("'\"")}, status_code=404)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
async def _body(request: Request) -> dict:
|
|
97
|
+
raw = await request.body()
|
|
98
|
+
if not raw:
|
|
99
|
+
return {}
|
|
100
|
+
try:
|
|
101
|
+
b = json.loads(raw)
|
|
102
|
+
except ValueError:
|
|
103
|
+
raise HTTPException(400, "invalid JSON body")
|
|
104
|
+
return b if isinstance(b, dict) else {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------- status
|
|
108
|
+
|
|
109
|
+
@app.get("/api/status")
|
|
110
|
+
def status():
|
|
111
|
+
gates, err = G.core()
|
|
112
|
+
return {
|
|
113
|
+
"bend": {**_core, "binary": find_bend()},
|
|
114
|
+
"gatesLoaded": gates is not None, "gatesError": err,
|
|
115
|
+
"llm": {"available": llm_available(), "model": DEFAULT_MODEL},
|
|
116
|
+
"claudeCode": claude_code_info(),
|
|
117
|
+
"openRouter": bool(os.environ.get("OPENROUTER_API_KEY")),
|
|
118
|
+
"defaults": {"failure": X.DEFAULT_FAILURE, "maxRecoveryAttempts": 3, "protectedPaths": DEFAULT_PROTECTED},
|
|
119
|
+
"assumptions": {"complianceModel": COMPLIANCE_MODEL, "baselineLoopCap": BASELINE_LOOP_CAP, "safetyRecoveryCap": SAFETY_RECOVERY_CAP,
|
|
120
|
+
"simulatedPricePerMTok": SIMULATED_PRICE_PER_MTOK},
|
|
121
|
+
"checks": {**CHECK_LABELS, "acceptance_suite": "CI acceptance suite passes (held-out tests the agent cannot see)"},
|
|
122
|
+
"catalog": [r["path"] for r in CATALOG],
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@app.get("/api/bend/sources")
|
|
127
|
+
def bend_sources():
|
|
128
|
+
return {k: (BEND_DIR / f).read_text() for k, f in (("governor", "governor.bend"), ("laws", "LAWS.bend"), ("proof", "PROOF.bend"))}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@app.post("/api/bend/recheck")
|
|
132
|
+
def bend_recheck():
|
|
133
|
+
st = build_and_check_core()
|
|
134
|
+
_core.clear()
|
|
135
|
+
_core.update(st.__dict__)
|
|
136
|
+
G.load_core_gates()
|
|
137
|
+
return _core
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ------------------------------------------------------------------ laws
|
|
141
|
+
|
|
142
|
+
def _laws_changed(out):
|
|
143
|
+
# attached sessions snapshot their laws when they start; the next hook call opens a record with the current laws
|
|
144
|
+
X.close_all_attached()
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@app.get("/api/laws")
|
|
149
|
+
def laws():
|
|
150
|
+
return R.list_laws()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@app.post("/api/laws/translate")
|
|
154
|
+
async def laws_translate(request: Request):
|
|
155
|
+
body = await _body(request)
|
|
156
|
+
text = str(body.get("text") or "").strip()
|
|
157
|
+
if not text:
|
|
158
|
+
raise HTTPException(400, "text is required")
|
|
159
|
+
t = await asyncio.to_thread(translate, text, body.get("useLlm") is not False)
|
|
160
|
+
drafts = [await asyncio.to_thread(R.preview_rule, r) for r in t["rules"]]
|
|
161
|
+
return {**t, "drafts": drafts}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.post("/api/laws/preview")
|
|
165
|
+
async def laws_preview(request: Request):
|
|
166
|
+
return await asyncio.to_thread(R.preview_rule, (await _body(request)).get("rule") or {})
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.post("/api/laws/activate")
|
|
170
|
+
async def laws_activate(request: Request):
|
|
171
|
+
body = await _body(request)
|
|
172
|
+
rules, names = body.get("rules") or [], body.get("names") or []
|
|
173
|
+
# appliesTo: optional scope for every activated rule (roles and/or agent ids); omitted = global
|
|
174
|
+
out = [await asyncio.to_thread(R.create_from_rule, r, names[i] if i < len(names) else None, body.get("appliesTo")) for i, r in enumerate(rules)]
|
|
175
|
+
return _laws_changed(out)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@app.post("/api/laws/bend/check")
|
|
179
|
+
async def laws_bend_check(request: Request):
|
|
180
|
+
return await asyncio.to_thread(R.preview_bend, str((await _body(request)).get("source") or ""))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.post("/api/laws/bend")
|
|
184
|
+
async def laws_bend(request: Request):
|
|
185
|
+
body = await _body(request)
|
|
186
|
+
if not body.get("name") or not body.get("source"):
|
|
187
|
+
raise HTTPException(400, "name and source are required")
|
|
188
|
+
law = await asyncio.to_thread(R.create_bend, str(body["name"]), str(body["source"]), str(body.get("description") or ""), body.get("appliesTo"))
|
|
189
|
+
return _laws_changed(law)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@app.patch("/api/laws/{law_id}")
|
|
193
|
+
async def laws_update(law_id: str, request: Request):
|
|
194
|
+
body = await _body(request)
|
|
195
|
+
kw = {k: body[k] for k in ("name", "description", "params", "source", "enabled") if k in body}
|
|
196
|
+
if "appliesTo" in body:
|
|
197
|
+
kw["applies_to"] = body["appliesTo"]
|
|
198
|
+
return _laws_changed(await asyncio.to_thread(R.update_law, law_id, **kw))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@app.post("/api/laws/{law_id}/validate")
|
|
202
|
+
def laws_validate(law_id: str):
|
|
203
|
+
return R.revalidate(law_id)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@app.delete("/api/laws/{law_id}")
|
|
207
|
+
def laws_delete(law_id: str):
|
|
208
|
+
R.delete_law(law_id)
|
|
209
|
+
return _laws_changed({"ok": True})
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ----------------------------------------------------------- experiments
|
|
213
|
+
|
|
214
|
+
def _live(exp_id: str) -> bool:
|
|
215
|
+
r = X.runner_for(exp_id)
|
|
216
|
+
return bool(r and not r.is_finished)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.get("/api/experiments")
|
|
220
|
+
def experiments_list():
|
|
221
|
+
return [{**e, "config": {**e["config"], "laws": [{"id": l["id"], "name": l["name"], "template": l["template"]} for l in e["config"]["laws"]]},
|
|
222
|
+
"live": _live(e["id"])} for e in store().experiments()]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@app.post("/api/experiments")
|
|
226
|
+
async def experiments_start(request: Request):
|
|
227
|
+
return X.start_experiment(await _body(request))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@app.post("/api/experiments/paired")
|
|
231
|
+
async def experiments_paired(request: Request):
|
|
232
|
+
body = await _body(request)
|
|
233
|
+
reps = int(body.get("repetitions") or 1)
|
|
234
|
+
if reps > 1:
|
|
235
|
+
return X.start_repetitions(body.get("config") or {}, reps)
|
|
236
|
+
return X.start_paired(body.get("config") or {})
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@app.get("/api/experiments/{exp_id}")
|
|
240
|
+
def experiment_get(exp_id: str):
|
|
241
|
+
e = store().get_experiment(exp_id)
|
|
242
|
+
if not e:
|
|
243
|
+
raise HTTPException(404, "experiment not found")
|
|
244
|
+
return {**e, "live": _live(exp_id), "humanActionsAvailable": X.runner_for(exp_id) is not None}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@app.get("/api/experiments/{exp_id}/events")
|
|
248
|
+
def experiment_events(exp_id: str, after: int = 0):
|
|
249
|
+
return store().events(exp_id, after)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@app.post("/api/experiments/{exp_id}/agents/{agent_id}/human")
|
|
253
|
+
async def experiment_human(exp_id: str, agent_id: str, request: Request):
|
|
254
|
+
body = await _body(request)
|
|
255
|
+
r = X.runner_for(exp_id)
|
|
256
|
+
if not r:
|
|
257
|
+
raise HTTPException(409, "This experiment is not loaded in the running server (it was recorded before a restart); human actions are unavailable.")
|
|
258
|
+
try:
|
|
259
|
+
r.human_action(agent_id, "cancel" if body.get("action") == "cancel" else "retry_with_guidance", body.get("note"))
|
|
260
|
+
except ValueError as e:
|
|
261
|
+
raise HTTPException(409, str(e))
|
|
262
|
+
return {"ok": True}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.get("/api/experiments/{exp_id}/stream")
|
|
266
|
+
async def experiment_stream(exp_id: str, request: Request, after: int = 0):
|
|
267
|
+
"""Server-sent events: backfill from the persisted log, then stream live events."""
|
|
268
|
+
loop = asyncio.get_running_loop()
|
|
269
|
+
queue: asyncio.Queue[list[dict]] = asyncio.Queue()
|
|
270
|
+
r = X.runner_for(exp_id)
|
|
271
|
+
# subscribe first, then backfill, so nothing falls between the two
|
|
272
|
+
unsub = r.subscribe(lambda evs: loop.call_soon_threadsafe(queue.put_nowait, evs)) if r else None
|
|
273
|
+
|
|
274
|
+
def status_frame() -> str:
|
|
275
|
+
e = store().get_experiment(exp_id)
|
|
276
|
+
return f"event: status\ndata: {json.dumps({'status': e['status'] if e else None})}\n\n"
|
|
277
|
+
|
|
278
|
+
async def gen():
|
|
279
|
+
last = after
|
|
280
|
+
|
|
281
|
+
def frame(evs: list[dict]) -> str | None:
|
|
282
|
+
nonlocal last
|
|
283
|
+
fresh = [e for e in evs if e["id"] > last]
|
|
284
|
+
if not fresh:
|
|
285
|
+
return None
|
|
286
|
+
last = fresh[-1]["id"]
|
|
287
|
+
return f"data: {json.dumps(fresh, separators=(',', ':'), ensure_ascii=False)}\n\n"
|
|
288
|
+
|
|
289
|
+
try:
|
|
290
|
+
backlog = store().events(exp_id, after)
|
|
291
|
+
for i in range(0, len(backlog), 2000):
|
|
292
|
+
if f := frame(backlog[i : i + 2000]):
|
|
293
|
+
yield f
|
|
294
|
+
yield status_frame()
|
|
295
|
+
while not await request.is_disconnected():
|
|
296
|
+
try:
|
|
297
|
+
evs = await asyncio.wait_for(queue.get(), timeout=2)
|
|
298
|
+
if f := frame(evs):
|
|
299
|
+
yield f
|
|
300
|
+
except asyncio.TimeoutError:
|
|
301
|
+
yield status_frame()
|
|
302
|
+
finally:
|
|
303
|
+
if unsub:
|
|
304
|
+
unsub()
|
|
305
|
+
|
|
306
|
+
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "Connection": "keep-alive"})
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ------------------------------------------------------ attached harnesses
|
|
310
|
+
# Governing an existing harness: the Claude Code hook posts every hook event here. Identity: a user's session
|
|
311
|
+
# (project, role, agent name); benchmark agents (exp + agent + token headers) arrive with the Python benchmarks.
|
|
312
|
+
|
|
313
|
+
def _owns(raw: Any) -> list[str] | None:
|
|
314
|
+
xs = [f"/repo/{x.strip().removeprefix('./').lstrip('/')}" for x in str(raw or "").split(",") if x.strip()]
|
|
315
|
+
return xs or None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _bridge_runner(request: Request, exp: str) -> RealRunner:
|
|
319
|
+
"""A benchmark run's agents (MCP bridge, harness hooks) authenticate with the run's own token."""
|
|
320
|
+
r = X.runner_for(exp)
|
|
321
|
+
if not isinstance(r, RealRunner):
|
|
322
|
+
raise HTTPException(404, "no live real-model run with this id")
|
|
323
|
+
if not hmac.compare_digest(request.headers.get("x-sc-token") or "", r.token):
|
|
324
|
+
raise HTTPException(403, "bad bridge token")
|
|
325
|
+
return r
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# MCP bridge for Claude Code agents: every tool call is gated by the runner.
|
|
329
|
+
@app.get("/api/bridge/{exp_id}/{agent_id}/tools")
|
|
330
|
+
def bridge_tools(exp_id: str, agent_id: str, request: Request):
|
|
331
|
+
return _bridge_runner(request, exp_id).bridge_tools(agent_id)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.post("/api/bridge/{exp_id}/{agent_id}/call")
|
|
335
|
+
async def bridge_call(exp_id: str, agent_id: str, request: Request):
|
|
336
|
+
r = _bridge_runner(request, exp_id)
|
|
337
|
+
body = await _body(request)
|
|
338
|
+
inp = body.get("input") if isinstance(body.get("input"), dict) else {}
|
|
339
|
+
return await asyncio.to_thread(r.bridge_call, agent_id, str(body.get("name") or ""), inp)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@app.post("/api/hooks/claude-code")
|
|
343
|
+
async def hooks_claude_code(request: Request):
|
|
344
|
+
body = await _body(request)
|
|
345
|
+
payload = body.get("payload") or {}
|
|
346
|
+
event = str(payload.get("hook_event_name") or "")
|
|
347
|
+
exp = request.headers.get("x-sc-exp")
|
|
348
|
+
if exp:
|
|
349
|
+
r = _bridge_runner(request, exp)
|
|
350
|
+
a = r.agent_by_id(request.headers.get("x-sc-agent") or "")
|
|
351
|
+
if not a:
|
|
352
|
+
raise HTTPException(404, "unknown agent")
|
|
353
|
+
return await asyncio.to_thread(r.hook_event, a, event, payload, None)
|
|
354
|
+
ident = body.get("identity") or {}
|
|
355
|
+
project = str(ident.get("project") or "default")
|
|
356
|
+
r = X.attached_runner(project, ident.get("mode") != "observe")
|
|
357
|
+
cwd = str(payload.get("cwd") or "")
|
|
358
|
+
owns = _owns(ident.get("owns"))
|
|
359
|
+
session = str(payload.get("session_id") or "session")
|
|
360
|
+
name = str(ident.get("agent") or f"session {session[:8]}")
|
|
361
|
+
role = str(ident.get("role") or "agent")
|
|
362
|
+
# Claude Code marks tool calls made inside a subagent with agent_id / agent_type: each subagent is its own agent,
|
|
363
|
+
# with its type as its role, and it inherits the session's laws (so a subagent can't be used to dodge them)
|
|
364
|
+
if payload.get("agent_id"):
|
|
365
|
+
sub = str(payload.get("agent_type") or "subagent")
|
|
366
|
+
a = r.attach_agent(f"{session}:{payload['agent_id']}", f"{name} / {sub}", sub, cwd, owns, {"names": [name], "roles": [role]})
|
|
367
|
+
else:
|
|
368
|
+
a = r.attach_agent(session, name, role, cwd, owns)
|
|
369
|
+
out = await asyncio.to_thread(r.hook_event, a, event, payload, body.get("doneCheck"))
|
|
370
|
+
# escalated while finishing: a hook that can wait holds the session open until a human decides
|
|
371
|
+
if ident.get("canWait") and event in ("Stop", "SubagentStop") and a.state == "awaiting_human":
|
|
372
|
+
return {**out, "governorWait": {"experiment": r.exp["id"], "agent": a.id}}
|
|
373
|
+
return out
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
@app.get("/api/attach/{exp_id}/agents/{agent_id}/decision")
|
|
377
|
+
def attach_decision(exp_id: str, agent_id: str):
|
|
378
|
+
r = X.runner_for(exp_id)
|
|
379
|
+
return r.human_decision(agent_id) if r else {"decided": True, "action": "gone", "note": ""}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@app.post("/api/attach/check")
|
|
383
|
+
async def attach_check(request: Request):
|
|
384
|
+
"""Any other harness or SDK: the same decision, with a neutral payload.
|
|
385
|
+
{ project, role, agent, session, owns, mode, event, tool, input, cwd, result, doneCheck, parent?: { agent, role } }"""
|
|
386
|
+
body = await _body(request)
|
|
387
|
+
r = X.attached_runner(str(body.get("project") or "default"), body.get("mode") != "observe")
|
|
388
|
+
p = body.get("parent") or {}
|
|
389
|
+
parent = {"names": [str(p["agent"])], "roles": [str(p.get("role") or "agent")]} if p.get("agent") else None
|
|
390
|
+
a = r.attach_agent(str(body.get("session") or body.get("agent") or "session"), str(body.get("agent") or body.get("session") or "agent"),
|
|
391
|
+
str(body.get("role") or "agent"), str(body.get("cwd") or ""), _owns(body.get("owns")), parent)
|
|
392
|
+
out = await asyncio.to_thread(r.hook_event, a, str(body.get("event") or "PreToolUse"),
|
|
393
|
+
{"tool_name": body.get("tool"), "tool_input": body.get("input") or {}, "cwd": body.get("cwd"), "tool_response": body.get("result")},
|
|
394
|
+
body.get("doneCheck"))
|
|
395
|
+
denied = out.get("hookSpecificOutput") or {}
|
|
396
|
+
blocked = denied.get("permissionDecision") == "deny" or out.get("decision") == "block"
|
|
397
|
+
return {"allow": not blocked, "reason": denied.get("permissionDecisionReason") or out.get("reason"), "agent": a.id, "experiment": r.exp["id"], "health": a.health}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@app.post("/api/attach/{exp_id}/close")
|
|
401
|
+
def attach_close(exp_id: str):
|
|
402
|
+
X.close_attached(exp_id)
|
|
403
|
+
return {"closed": exp_id}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
@app.post("/api/benchmarks/{bench_id}/invalidate")
|
|
407
|
+
async def benchmarks_invalidate(bench_id: str, request: Request):
|
|
408
|
+
"""Mark benchmark runs invalid (e.g. usage-limit errors) so reports flag them."""
|
|
409
|
+
body = await _body(request)
|
|
410
|
+
for exp_id in body.get("ids") or []:
|
|
411
|
+
e = store().get_experiment(exp_id)
|
|
412
|
+
if e and e["config"].get("benchmarkId") == bench_id:
|
|
413
|
+
store().put_experiment({**e, "config": {**e["config"], "invalid": str(body.get("reason") or "invalid")}})
|
|
414
|
+
return {"ok": True}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
@app.get("/api/export")
|
|
418
|
+
def export(format: str = "trajectories", experiments: str | None = None):
|
|
419
|
+
"""JSONL export for analysis / fine-tuning: ?format=trajectories|sft|preferences&experiments=id,id (default: all)."""
|
|
420
|
+
fmt = format if format in ("trajectories", "sft", "preferences") else "trajectories"
|
|
421
|
+
ids = [x for x in experiments.split(",") if x] if experiments else [e["id"] for e in store().experiments()]
|
|
422
|
+
return Response("\n".join(export_lines(fmt, ids)) + "\n", media_type="application/x-ndjson",
|
|
423
|
+
headers={"Content-Disposition": f'attachment; filename="{fmt}.jsonl"'})
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@app.api_route("/api/{rest:path}", methods=["GET", "POST", "PATCH", "PUT", "DELETE"])
|
|
427
|
+
def api_not_found(rest: str):
|
|
428
|
+
raise HTTPException(404, "not found")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ---------------------------------------------------------- scripts and UI
|
|
432
|
+
|
|
433
|
+
@app.get("/hook.py")
|
|
434
|
+
def hook_script():
|
|
435
|
+
"""The Claude Code hook (standard library only), so developers can connect without installing anything."""
|
|
436
|
+
return PlainTextResponse((PACKAGE / "hook.py").read_text(), media_type="text/x-python; charset=utf-8")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
@app.get("/connect.py")
|
|
440
|
+
def connect_script():
|
|
441
|
+
return PlainTextResponse((PACKAGE / "connect.py").read_text(), media_type="text/x-python; charset=utf-8")
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@app.get("/{path:path}")
|
|
445
|
+
def ui(path: str):
|
|
446
|
+
dist = _dist()
|
|
447
|
+
if not dist:
|
|
448
|
+
raise HTTPException(404, "UI not built: run `npm run build` in a checkout, or install the published package")
|
|
449
|
+
f = (dist / path).resolve()
|
|
450
|
+
if path and f.is_file() and f.is_relative_to(dist.resolve()):
|
|
451
|
+
return FileResponse(f)
|
|
452
|
+
return FileResponse(dist / "index.html")
|