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.
Files changed (123) hide show
  1. swarmcontrol/__init__.py +3 -0
  2. swarmcontrol/api.py +452 -0
  3. swarmcontrol/benchtools.py +588 -0
  4. swarmcontrol/bend/LAWS.bend +65 -0
  5. swarmcontrol/bend/PROOF.bend +123 -0
  6. swarmcontrol/bend/__init__.py +0 -0
  7. swarmcontrol/bend/adapter.py +515 -0
  8. swarmcontrol/bend/custom_driver.bend.txt +113 -0
  9. swarmcontrol/bend/gates.bend +130 -0
  10. swarmcontrol/bend/gates.py +95 -0
  11. swarmcontrol/bend/governor.bend +118 -0
  12. swarmcontrol/bend/toolchain.py +200 -0
  13. swarmcontrol/cli.py +56 -0
  14. swarmcontrol/connect.py +255 -0
  15. swarmcontrol/env.py +19 -0
  16. swarmcontrol/experiments.py +190 -0
  17. swarmcontrol/export.py +140 -0
  18. swarmcontrol/hook.py +113 -0
  19. swarmcontrol/laws/__init__.py +0 -0
  20. swarmcontrol/laws/registry.py +196 -0
  21. swarmcontrol/laws/scope.py +30 -0
  22. swarmcontrol/laws/team.py +14 -0
  23. swarmcontrol/laws/translate.py +133 -0
  24. swarmcontrol/llm.py +142 -0
  25. swarmcontrol/metrics.py +196 -0
  26. swarmcontrol/paths.py +21 -0
  27. swarmcontrol/real/__init__.py +0 -0
  28. swarmcontrol/real/backends.py +693 -0
  29. swarmcontrol/real/bench.py +862 -0
  30. swarmcontrol/real/claude_code.py +27 -0
  31. swarmcontrol/real/delegated.py +163 -0
  32. swarmcontrol/real/dojo.py +326 -0
  33. swarmcontrol/real/families.py +255 -0
  34. swarmcontrol/real/hooks.py +207 -0
  35. swarmcontrol/real/jsutil.py +168 -0
  36. swarmcontrol/real/mcp_bridge.py +80 -0
  37. swarmcontrol/real/or_proxy.py +152 -0
  38. swarmcontrol/real/runner.py +853 -0
  39. swarmcontrol/real/scenarios.py +109 -0
  40. swarmcontrol/real/shift.py +140 -0
  41. swarmcontrol/real/tau2.py +513 -0
  42. swarmcontrol/real/tau2_retail.py +317 -0
  43. swarmcontrol/runtime/__init__.py +0 -0
  44. swarmcontrol/runtime/sandbox.py +90 -0
  45. swarmcontrol/runtime/simulator.py +902 -0
  46. swarmcontrol/scenarios/bugfix-repo/README.md +5 -0
  47. swarmcontrol/scenarios/bugfix-repo/package.json +7 -0
  48. swarmcontrol/scenarios/bugfix-repo/src/backoff.js +4 -0
  49. swarmcontrol/scenarios/bugfix-repo/src/csv.js +4 -0
  50. swarmcontrol/scenarios/bugfix-repo/src/email.js +4 -0
  51. swarmcontrol/scenarios/bugfix-repo/src/leap.js +4 -0
  52. swarmcontrol/scenarios/bugfix-repo/src/lru.js +15 -0
  53. swarmcontrol/scenarios/bugfix-repo/src/merge.js +9 -0
  54. swarmcontrol/scenarios/bugfix-repo/src/money.js +6 -0
  55. swarmcontrol/scenarios/bugfix-repo/src/paginate.js +9 -0
  56. swarmcontrol/scenarios/bugfix-repo/src/percent.js +4 -0
  57. swarmcontrol/scenarios/bugfix-repo/src/ranges.js +4 -0
  58. swarmcontrol/scenarios/bugfix-repo/src/semver.js +5 -0
  59. swarmcontrol/scenarios/bugfix-repo/src/slugify.js +6 -0
  60. swarmcontrol/scenarios/bugfix-repo/test/backoff.test.js +8 -0
  61. swarmcontrol/scenarios/bugfix-repo/test/csv.test.js +8 -0
  62. swarmcontrol/scenarios/bugfix-repo/test/email.test.js +8 -0
  63. swarmcontrol/scenarios/bugfix-repo/test/leap.test.js +8 -0
  64. swarmcontrol/scenarios/bugfix-repo/test/lru.test.js +22 -0
  65. swarmcontrol/scenarios/bugfix-repo/test/merge.test.js +12 -0
  66. swarmcontrol/scenarios/bugfix-repo/test/money.test.js +8 -0
  67. swarmcontrol/scenarios/bugfix-repo/test/paginate.test.js +9 -0
  68. swarmcontrol/scenarios/bugfix-repo/test/percent.test.js +8 -0
  69. swarmcontrol/scenarios/bugfix-repo/test/ranges.test.js +8 -0
  70. swarmcontrol/scenarios/bugfix-repo/test/semver.test.js +8 -0
  71. swarmcontrol/scenarios/bugfix-repo/test/slugify.test.js +8 -0
  72. swarmcontrol/scenarios/delegated/SOURCE.md +4 -0
  73. swarmcontrol/scenarios/delegated/tickets.json +2618 -0
  74. swarmcontrol/scenarios/ledgerkit/.ci/pipeline.yml +1 -0
  75. swarmcontrol/scenarios/ledgerkit/README.md +5 -0
  76. swarmcontrol/scenarios/ledgerkit/package.json +1 -0
  77. swarmcontrol/scenarios/ledgerkit/src/budget.js +9 -0
  78. swarmcontrol/scenarios/ledgerkit/src/csvexport.js +11 -0
  79. swarmcontrol/scenarios/ledgerkit/src/cursor.js +9 -0
  80. swarmcontrol/scenarios/ledgerkit/src/dedupe.js +9 -0
  81. swarmcontrol/scenarios/ledgerkit/src/fx.js +11 -0
  82. swarmcontrol/scenarios/ledgerkit/src/interest.js +11 -0
  83. swarmcontrol/scenarios/ledgerkit/src/invoice.js +12 -0
  84. swarmcontrol/scenarios/ledgerkit/src/permissions.js +12 -0
  85. swarmcontrol/scenarios/ledgerkit/src/ratelimit.js +21 -0
  86. swarmcontrol/scenarios/ledgerkit/src/reconcile.js +13 -0
  87. swarmcontrol/scenarios/ledgerkit/src/schedule.js +15 -0
  88. swarmcontrol/scenarios/ledgerkit/src/tax.js +11 -0
  89. swarmcontrol/scenarios/ledgerkit/src/util/dates.js +7 -0
  90. swarmcontrol/scenarios/ledgerkit/src/util/money.js +13 -0
  91. swarmcontrol/scenarios/ledgerkit/test/budget.test.js +8 -0
  92. swarmcontrol/scenarios/ledgerkit/test/csvexport.test.js +8 -0
  93. swarmcontrol/scenarios/ledgerkit/test/cursor.test.js +8 -0
  94. swarmcontrol/scenarios/ledgerkit/test/dedupe.test.js +9 -0
  95. swarmcontrol/scenarios/ledgerkit/test/fx.test.js +9 -0
  96. swarmcontrol/scenarios/ledgerkit/test/interest.test.js +8 -0
  97. swarmcontrol/scenarios/ledgerkit/test/invoice.test.js +8 -0
  98. swarmcontrol/scenarios/ledgerkit/test/permissions.test.js +9 -0
  99. swarmcontrol/scenarios/ledgerkit/test/ratelimit.test.js +9 -0
  100. swarmcontrol/scenarios/ledgerkit/test/reconcile.test.js +8 -0
  101. swarmcontrol/scenarios/ledgerkit/test/schedule.test.js +8 -0
  102. swarmcontrol/scenarios/ledgerkit/test/tax.test.js +9 -0
  103. swarmcontrol/scenarios/sources/tau2-airline/SOURCE.md +5 -0
  104. swarmcontrol/scenarios/sources/tau2-airline/db.json +205193 -0
  105. swarmcontrol/scenarios/sources/tau2-airline/policy.md +167 -0
  106. swarmcontrol/scenarios/sources/tau2-airline/tasks.json +3521 -0
  107. swarmcontrol/scenarios/sources/tau2-retail/SOURCE.md +5 -0
  108. swarmcontrol/scenarios/sources/tau2-retail/db.json +83752 -0
  109. swarmcontrol/scenarios/sources/tau2-retail/policy.md +136 -0
  110. swarmcontrol/scenarios/sources/tau2-retail/tasks.json +9037 -0
  111. swarmcontrol/sim/__init__.py +0 -0
  112. swarmcontrol/sim/corpus.py +212 -0
  113. swarmcontrol/sim/rng.py +67 -0
  114. swarmcontrol/store.py +115 -0
  115. swarmcontrol/web/assets/index-B9lQhwVL.css +1 -0
  116. swarmcontrol/web/assets/index-Dz_YLbc2.js +82 -0
  117. swarmcontrol/web/index.html +16 -0
  118. swarmcontrol-0.1.0.dist-info/METADATA +324 -0
  119. swarmcontrol-0.1.0.dist-info/RECORD +123 -0
  120. swarmcontrol-0.1.0.dist-info/WHEEL +4 -0
  121. swarmcontrol-0.1.0.dist-info/entry_points.txt +3 -0
  122. swarmcontrol-0.1.0.dist-info/licenses/LICENSE +202 -0
  123. swarmcontrol-0.1.0.dist-info/licenses/NOTICE +20 -0
@@ -0,0 +1,3 @@
1
+ """SwarmControl: the control loop for AI agent swarms (runtime control plane)."""
2
+
3
+ __version__ = "0.1.0"
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")