AdithyaSK HF Staff commited on
Commit
6abb7be
·
verified ·
1 Parent(s): 2233001

Deploy HF RL Explorer

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
CUSTOM_ENVS.md CHANGED
@@ -107,6 +107,15 @@ can't load the renderer show.
107
 
108
  ## Running
109
 
 
 
 
 
 
 
 
 
 
110
  A run option is one way to run a task, by a **runner**:
111
 
112
  - `harbor`: OpenEnv's Harbor runner on an HF Sandbox (`app/runner.py`). Implement `materialize` (the task as a Harbor
@@ -189,8 +198,10 @@ A processor can say more than a row:
189
  as dotted names, through lists. Put a format's set in `processors.HIDDEN` too: search never looks at them.
190
 
191
  **The framework readers.** NeMo Gym, Verifiers and verl / SkyRL rows are read by modules of their own, which show a
192
- row as its framework sees it and say how to run it there (they never run here: the sandbox runner runs Harbor tasks,
193
- and these need their framework's servers, sandboxes or trainers):
 
 
194
 
195
  | module | knows | how it's kept current |
196
  |---|---|---|
 
107
 
108
  ## Running
109
 
110
+ Every summary and task also receives a versioned `support` descriptor from
111
+ `app/envs/capabilities.py`. It separates the source's framework, lifecycle, reward
112
+ semantics, transport and available capabilities. The UI and dataset MCP tools consume
113
+ the same descriptor. States are `available`, `external`, `unavailable`, and `unknown`;
114
+ only an implemented hosted runner can advertise execution here. Register a new
115
+ framework's documentation/lifecycle there and add its runner to `HOSTED_RUNNERS` only
116
+ after implementing dispatch and validation. See [EXPLORER_AUDIT.md](EXPLORER_AUDIT.md)
117
+ for the framework matrix and live protocol contract.
118
+
119
  A run option is one way to run a task, by a **runner**:
120
 
121
  - `harbor`: OpenEnv's Harbor runner on an HF Sandbox (`app/runner.py`). Implement `materialize` (the task as a Harbor
 
198
  as dotted names, through lists. Put a format's set in `processors.HIDDEN` too: search never looks at them.
199
 
200
  **The framework readers.** NeMo Gym, Verifiers and verl / SkyRL rows are read by modules of their own, which show a
201
+ row as its framework sees it and say how to run it there. Verifiers, verl / SkyRL and most NeMo
202
+ configurations require their native servers, sandboxes or trainers. The two supported NeMo Pivot tool-simulation
203
+ configurations can also run here through Responses inference and NVIDIA's pinned argument verifier; they predict
204
+ function calls without executing the functions. `nemogym.run_option` validates this narrow hosted subset:
205
 
206
  | module | knows | how it's kept current |
207
  |---|---|---|
app/admin.py CHANGED
@@ -19,7 +19,7 @@ from typing import Any, Literal
19
  from fastapi import APIRouter, HTTPException, Request
20
  from pydantic import BaseModel, Field
21
 
22
- from . import auth, catalog, config, runner, settings, store
23
 
24
  STALE = 600 # a rollout whose record hasn't changed for this long is no longer live, whatever it says
25
 
@@ -40,22 +40,29 @@ def _admin(request: Request) -> dict[str, Any]:
40
  @router.get("/overview")
41
  def overview(request: Request):
42
  _admin(request)
43
- envs = catalog.environments(include_hidden=True)
44
  runs = _runs()
45
  now = time.time()
46
  day = 86400
47
  by_day = Counter(time.strftime("%Y-%m-%d", time.gmtime(r.get("created_at", 0))) for r in runs if now - r.get("created_at", 0) < 14 * day)
48
  finished = [r for r in runs if r.get("status") in ("done", "failed", "cancelled", "interrupted")]
49
  new = sorted((e for e in envs if e.get("created") and now - _ts(e["created"]) < 14 * day), key=lambda e: e["created"], reverse=True)
 
 
 
 
50
  idx_files = list(config.INDEX_DIR.glob("*.json.gz"))
51
  pack_files = list(config.PACK_DIR.glob("*.json.gz")) if config.PACK_DIR.is_dir() else []
52
  return {
53
  "environments": {"datasets": sum(1 for e in envs if e["kind"] == "dataset"), "spaces": sum(1 for e in envs if e["kind"] == "space"),
54
- "openenv": sum(1 for e in envs if e.get("openenv")), "indexed": sum(1 for e in envs if e.get("indexed")),
55
- "hidden": len(catalog.hidden()), "pinned": len(catalog.pinned()), "collections": len(catalog.collections())},
 
56
  "rollouts": {"total": len(runs), "live": sum(1 for r in runs if _live(r)), "today": sum(1 for r in runs if now - r.get("created_at", 0) < day),
57
  "week": sum(1 for r in runs if now - r.get("created_at", 0) < 7 * day),
58
- "public": sum(1 for r in runs if r.get("visibility") == "public"),
 
 
59
  "failed": sum(1 for r in finished if r.get("status") != "done"), "finished": len(finished),
60
  "users": len({r.get("user") for r in runs}),
61
  "by_day": [[time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), by_day.get(time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), 0)]
@@ -65,6 +72,8 @@ def overview(request: Request):
65
  "storage": {"indexes": len(idx_files), "index_bytes": sum(f.stat().st_size for f in idx_files),
66
  "packs": len(pack_files), "pack_bytes": sum(f.stat().st_size for f in pack_files)},
67
  "recent": [_admin_view(r) for r in runs[:8]],
 
 
68
  "new": new[:12],
69
  "settings": _settings(),
70
  }
@@ -117,6 +126,8 @@ def _admin_view(r: dict[str, Any]) -> dict[str, Any]:
117
  v = dict(r)
118
  if v.get("endpoint"):
119
  v["endpoint"] = {"host": v["endpoint"].get("host"), "model": v["endpoint"].get("model")}
 
 
120
  v["live"] = _live(r)
121
  v["avatar"] = r.get("avatar") or avatar(r.get("user"))
122
  v["moderated"] = r["id"] in set(settings.get("hidden_runs", []))
@@ -125,14 +136,22 @@ def _admin_view(r: dict[str, Any]) -> dict[str, Any]:
125
 
126
  # ── rollouts ─────────────────────────────────────────────────────────────────
127
  @router.get("/rollouts")
128
- def rollouts(request: Request, status: str = "", q: str = "", user: str = "", limit: int = 100, offset: int = 0):
 
129
  _admin(request)
130
  ql = q.strip().lower()
131
- runs = [r for r in _runs()
 
 
132
  if (not status or (status == "live" and _live(r)) or r.get("status") == status)
133
  and (not user or r.get("user") == user)
134
- and (not ql or ql in f"{r.get('title', '')} {r.get('dataset', '')} {r.get('model', '')} {r.get('user', '')} {r['id']}".lower())]
135
- return {"total": len(runs), "runs": [_admin_view(r) for r in runs[offset: offset + max(1, min(limit, 500))]]}
 
 
 
 
 
136
 
137
 
138
  @router.post("/rollouts/{run_id}/cancel")
@@ -173,14 +192,14 @@ def people(request: Request):
173
  by: dict[str, dict[str, Any]] = {}
174
  for r in _runs():
175
  name = r.get("user") or "unknown"
176
- p = by.setdefault(name, {"name": name, "avatar": None, "runs": 0, "done": 0, "solved": 0, "live": 0, "public": 0,
177
  "first": r.get("created_at"), "last": r.get("created_at"), "models": Counter(), "datasets": Counter()})
178
  p["avatar"] = p["avatar"] or r.get("avatar")
179
  p["runs"] += 1
180
  p["done"] += r.get("status") == "done"
181
- p["solved"] += (r.get("reward") or 0) >= 0.999
182
  p["live"] += _live(r)
183
- p["public"] += r.get("visibility") == "public"
184
  p["first"] = min(p["first"] or 0, r.get("created_at") or 0)
185
  p["last"] = max(p["last"] or 0, r.get("created_at") or 0)
186
  p["models"][r.get("model") or "custom endpoint"] += 1
@@ -197,10 +216,10 @@ def people(request: Request):
197
  @router.get("/environments")
198
  def environments(request: Request):
199
  _admin(request)
200
- envs = catalog.environments(include_hidden=True)
201
  gone = catalog.hidden()
202
  runs = Counter(r.get("dataset") for r in _runs())
203
- return {"environments": [{**e, "hidden": e["key"] in gone, "rollouts": runs.get(e["id"], 0) if e["kind"] == "dataset" else 0} for e in envs],
204
  "collections": catalog.collections(), "jobs": _jobs()}
205
 
206
 
@@ -238,7 +257,7 @@ def env_action(body: EnvAction, request: Request):
238
  settings.save(u["name"], {"collections": cols}, note=f"{body.action} {key}" + (f" in {body.collection}" if body.collection else ""))
239
  elif body.action == "index":
240
  if key.startswith("space:"):
241
- raise HTTPException(400, "Spaces aren't indexed")
242
  st = catalog.index_status(key) # anonymous: public datasets only
243
  settings.save(u["name"], {}, note=f"indexed {key}")
244
  return {"ok": True, "job": {k: v for k, v in st.items() if k != "index"}}
@@ -339,4 +358,22 @@ def indexes(request: Request):
339
  out.append({"spec": f.name[:-8].replace("__", "/", 1), "bytes": f.stat().st_size, "pack_bytes": pack.stat().st_size if pack.exists() else None,
340
  "tasks": head.get("tasks"), "version": head.get("version"), "current": head.get("version") == catalog.INDEX_VERSION,
341
  "built": head.get("built")})
342
- return {"indexes": out, "version": catalog.INDEX_VERSION, "jobs": _jobs()}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  from fastapi import APIRouter, HTTPException, Request
20
  from pydantic import BaseModel, Field
21
 
22
+ from . import auth, auto_index, catalog, community, config, runner, search_api, settings, snapshot, space_checks, space_tasks, store
23
 
24
  STALE = 600 # a rollout whose record hasn't changed for this long is no longer live, whatever it says
25
 
 
40
  @router.get("/overview")
41
  def overview(request: Request):
42
  _admin(request)
43
+ envs = search_api.environments(include_hidden=True)
44
  runs = _runs()
45
  now = time.time()
46
  day = 86400
47
  by_day = Counter(time.strftime("%Y-%m-%d", time.gmtime(r.get("created_at", 0))) for r in runs if now - r.get("created_at", 0) < 14 * day)
48
  finished = [r for r in runs if r.get("status") in ("done", "failed", "cancelled", "interrupted")]
49
  new = sorted((e for e in envs if e.get("created") and now - _ts(e["created"]) < 14 * day), key=lambda e: e["created"], reverse=True)
50
+ hidden_runs = set(settings.get("hidden_runs", []))
51
+ ctx = search_api.context()
52
+ with snapshot.use() as (_, conn):
53
+ public_counts = search_api.sql_extra(conn, ctx, 0, search_api.Query(scope="ready"))["stats"]
54
  idx_files = list(config.INDEX_DIR.glob("*.json.gz"))
55
  pack_files = list(config.PACK_DIR.glob("*.json.gz")) if config.PACK_DIR.is_dir() else []
56
  return {
57
  "environments": {"datasets": sum(1 for e in envs if e["kind"] == "dataset"), "spaces": sum(1 for e in envs if e["kind"] == "space"),
58
+ "openenv": sum(space_checks.verified(e.get("api_check")) for e in envs), "indexed": sum(1 for e in envs if e.get("indexed")),
59
+ "checked_spaces": sum(space_checks.browseable(e.get("api_check")) for e in envs),
60
+ "public": public_counts, "hidden": len(catalog.hidden()), "pinned": len(catalog.pinned()), "collections": len(catalog.collections())},
61
  "rollouts": {"total": len(runs), "live": sum(1 for r in runs if _live(r)), "today": sum(1 for r in runs if now - r.get("created_at", 0) < day),
62
  "week": sum(1 for r in runs if now - r.get("created_at", 0) < 7 * day),
63
+ "public": sum(community.is_public(r, hidden_runs) for r in runs),
64
+ "private": sum(r.get("visibility") != "public" for r in runs),
65
+ "by_runner": Counter(community.runner(r) for r in runs).most_common(),
66
  "failed": sum(1 for r in finished if r.get("status") != "done"), "finished": len(finished),
67
  "users": len({r.get("user") for r in runs}),
68
  "by_day": [[time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), by_day.get(time.strftime("%Y-%m-%d", time.gmtime(now - i * day)), 0)]
 
72
  "storage": {"indexes": len(idx_files), "index_bytes": sum(f.stat().st_size for f in idx_files),
73
  "packs": len(pack_files), "pack_bytes": sum(f.stat().st_size for f in pack_files)},
74
  "recent": [_admin_view(r) for r in runs[:8]],
75
+ "live": [_admin_view(r) for r in runs if _live(r)][:24],
76
+ "operations": _operations(envs),
77
  "new": new[:12],
78
  "settings": _settings(),
79
  }
 
126
  v = dict(r)
127
  if v.get("endpoint"):
128
  v["endpoint"] = {"host": v["endpoint"].get("host"), "model": v["endpoint"].get("model")}
129
+ v["runner"] = community.runner(r)
130
+ v["community"] = community.is_public(r, set(settings.get("hidden_runs", [])))
131
  v["live"] = _live(r)
132
  v["avatar"] = r.get("avatar") or avatar(r.get("user"))
133
  v["moderated"] = r["id"] in set(settings.get("hidden_runs", []))
 
136
 
137
  # ── rollouts ─────────────────────────────────────────────────────────────────
138
  @router.get("/rollouts")
139
+ def rollouts(request: Request, status: str = "", q: str = "", user: str = "", runner: str = "",
140
+ visibility: str = "", limit: int = 100, offset: int = 0):
141
  _admin(request)
142
  ql = q.strip().lower()
143
+ hidden = set(settings.get("hidden_runs", []))
144
+ all_runs = _runs()
145
+ runs = [r for r in all_runs
146
  if (not status or (status == "live" and _live(r)) or r.get("status") == status)
147
  and (not user or r.get("user") == user)
148
+ and (not runner or community.runner(r) == runner)
149
+ and (not visibility or (visibility == "community" and community.is_public(r, hidden))
150
+ or (visibility == "moderated" and r["id"] in hidden) or r.get("visibility") == visibility)
151
+ and (not ql or ql in f"{r.get('title', '')} {r.get('dataset', '')} {r.get('model', '')} {r.get('user', '')} {r['id']} {community.runner(r)}".lower())]
152
+ offset = max(0, offset)
153
+ return {"total": len(runs), "runners": sorted({community.runner(r) for r in all_runs}),
154
+ "runs": [_admin_view(r) for r in runs[offset:offset + max(1, min(limit, 500))]]}
155
 
156
 
157
  @router.post("/rollouts/{run_id}/cancel")
 
192
  by: dict[str, dict[str, Any]] = {}
193
  for r in _runs():
194
  name = r.get("user") or "unknown"
195
+ p = by.setdefault(name, {"name": name, "avatar": None, "runs": 0, "done": 0, "scored": 0, "live": 0, "public": 0,
196
  "first": r.get("created_at"), "last": r.get("created_at"), "models": Counter(), "datasets": Counter()})
197
  p["avatar"] = p["avatar"] or r.get("avatar")
198
  p["runs"] += 1
199
  p["done"] += r.get("status") == "done"
200
+ p["scored"] += r.get("status") == "done" and community.scalar_reward(r)
201
  p["live"] += _live(r)
202
+ p["public"] += community.is_public(r, set(settings.get("hidden_runs", [])))
203
  p["first"] = min(p["first"] or 0, r.get("created_at") or 0)
204
  p["last"] = max(p["last"] or 0, r.get("created_at") or 0)
205
  p["models"][r.get("model") or "custom endpoint"] += 1
 
216
  @router.get("/environments")
217
  def environments(request: Request):
218
  _admin(request)
219
+ envs = search_api.environments(include_hidden=True)
220
  gone = catalog.hidden()
221
  runs = Counter(r.get("dataset") for r in _runs())
222
+ return {"environments": [{**e, "hidden": e["key"] in gone, "ready": e["kind"] == "dataset" or space_checks.browseable(e.get("api_check")), "rollouts": runs.get(e["id"], 0) if e["kind"] == "dataset" else 0} for e in envs],
223
  "collections": catalog.collections(), "jobs": _jobs()}
224
 
225
 
 
257
  settings.save(u["name"], {"collections": cols}, note=f"{body.action} {key}" + (f" in {body.collection}" if body.collection else ""))
258
  elif body.action == "index":
259
  if key.startswith("space:"):
260
+ raise HTTPException(400, "Space task counts are discovered automatically from their Task API; dataset indexes are separate")
261
  st = catalog.index_status(key) # anonymous: public datasets only
262
  settings.save(u["name"], {}, note=f"indexed {key}")
263
  return {"ok": True, "job": {k: v for k, v in st.items() if k != "index"}}
 
358
  out.append({"spec": f.name[:-8].replace("__", "/", 1), "bytes": f.stat().st_size, "pack_bytes": pack.stat().st_size if pack.exists() else None,
359
  "tasks": head.get("tasks"), "version": head.get("version"), "current": head.get("version") == catalog.INDEX_VERSION,
360
  "built": head.get("built")})
361
+ return {"indexes": out, "version": catalog.INDEX_VERSION, "jobs": _jobs(), "operations": _operations()}
362
+
363
+
364
+ def _operations(envs=None):
365
+ """Evidence from the shared store, not the admin process's worker settings."""
366
+ envs = envs if envs is not None else search_api.environments(include_hidden=True)
367
+ spaces = [e for e in envs if e["kind"] == "space"]
368
+ records = space_checks.inventory()
369
+ states = Counter(space_checks.status(records.get(e["id"])) for e in spaces)
370
+ versions = Counter(e.get("declared_version") or "Unknown" for e in spaces if space_checks.verified(records.get(e["id"])))
371
+ job = auto_index.status()
372
+ now = time.time()
373
+ stale = bool(job and now > (job.get("next_at") or job.get("started_at") or 0) + auto_index.INTERVAL)
374
+ return {"snapshot": snapshot.status(), "indexer": {**job, "stale": stale},
375
+ "checks": {"candidates": len(spaces), "ready": sum(space_checks.browseable(records.get(e["id"])) for e in spaces),
376
+ "openenv": sum(space_checks.verified(records.get(e["id"])) for e in spaces),
377
+ "statuses": dict(states), "versions": versions.most_common(), "fresh_seconds": space_checks.FRESH,
378
+ "latest_at": max((records.get(e["id"], {}).get("checked_at", 0) for e in spaces), default=0)},
379
+ "task_catalog": space_tasks.census(records, [e["id"] for e in spaces if e["key"] not in catalog.hidden() and space_checks.browseable(records.get(e["id"]))])}
app/admin_app.py CHANGED
@@ -34,12 +34,13 @@ ADMIN_WEB = config.ROOT / "web-admin"
34
  def _startup() -> None:
35
  import threading
36
 
37
- from . import catalog
38
 
39
  config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
40
  config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
41
  # the Hub listing the dashboard opens with: fetched now, so the first visit doesn't wait on it
42
- threading.Thread(target=lambda: catalog.environments(include_hidden=True), daemon=True, name="warm").start()
 
43
 
44
 
45
  @app.get("/healthz", include_in_schema=False)
 
34
  def _startup() -> None:
35
  import threading
36
 
37
+ from . import search_api, snapshot
38
 
39
  config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
40
  config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
41
  # the Hub listing the dashboard opens with: fetched now, so the first visit doesn't wait on it
42
+ snapshot.start()
43
+ threading.Thread(target=lambda: search_api.environments(include_hidden=True), daemon=True, name="warm").start()
44
 
45
 
46
  @app.get("/healthz", include_in_schema=False)
app/auto_index.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded automatic public catalog maintenance, independent of page visits.
2
+
3
+ The existing indexer runs in a child process so stalled downloads cannot leave
4
+ builder threads behind. A process lease prevents duplicate app workers from
5
+ publishing concurrently. No HF Jobs, inference, or episode execution is used.
6
+ Deployments with an external indexer can set RLX_AUTO_INDEX=0.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import gzip
11
+ import json
12
+ import logging
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import threading
17
+ import time
18
+
19
+ from . import catalog, config
20
+
21
+ INTERVAL = 600
22
+ LISTING_REFRESH = 3600
23
+ MAX_SECONDS = 600
24
+ _stop = threading.Event()
25
+ _worker = None
26
+ log = logging.getLogger("rlx")
27
+
28
+
29
+ def status():
30
+ try:
31
+ record = json.loads((config.STORAGE_DIR / "auto-index.json").read_bytes())
32
+ return record if isinstance(record, dict) else {}
33
+ except (OSError, ValueError):
34
+ return {}
35
+
36
+
37
+ def _save(record):
38
+ catalog.atomic_write(config.STORAGE_DIR / "auto-index.json", json.dumps(record).encode())
39
+
40
+
41
+ def cached_listing(now):
42
+ """Reuse only a recent complete public discovery; never extend its age."""
43
+ try:
44
+ with gzip.open(config.STORAGE_DIR / "listing.json.gz", "rt") as f:
45
+ doc = json.load(f)
46
+ if not isinstance(doc, dict):
47
+ return None, None
48
+ at = doc.get("at", 0)
49
+ if doc.get("full") and isinstance(doc.get("rows"), list) and doc["rows"] and 0 <= now - at < LISTING_REFRESH:
50
+ return doc["rows"], at
51
+ except (OSError, ValueError, TypeError):
52
+ pass
53
+ return None, None
54
+
55
+
56
+ def cycle():
57
+ """One lease-protected cycle; publication remains atomic in indexer.run."""
58
+ import fcntl
59
+ from . import indexer, snapshot
60
+
61
+ config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
62
+ with (config.CACHE_DIR / "auto-index.lock").open("a") as lease:
63
+ try:
64
+ fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
65
+ except BlockingIOError:
66
+ return
67
+ now = time.time()
68
+ previous = status()
69
+ if previous.get("next_at", 0) > now:
70
+ return
71
+ record = {"state": "indexing", "started_at": now, "last_success": previous.get("last_success"),
72
+ "failures": previous.get("failures", 0), "next_at": now + MAX_SECONDS + INTERVAL}
73
+ _save(record)
74
+ try:
75
+ rows, at = cached_listing(now)
76
+ report = indexer.run(snapshot.open_store(), rows=rows, listing_at=at, budget_s=90, max_builds=4)
77
+ _save({"state": "idle", "last_success": time.time(), "next_at": time.time() + INTERVAL,
78
+ "failures": 0, "counts": report["counts"]})
79
+ except Exception as exc:
80
+ failures = record["failures"] + 1
81
+ _save({**record, "state": "retrying", "failures": failures, "error": type(exc).__name__,
82
+ "next_at": time.time() + min(3600, INTERVAL * 2 ** min(failures - 1, 3))})
83
+ log.warning("Automatic catalog update failed: %s", type(exc).__name__)
84
+
85
+
86
+ def _child():
87
+ # A public index must not accidentally depend on a developer's HF login.
88
+ env = {**os.environ, "HF_HUB_DISABLE_IMPLICIT_TOKEN": "1"}
89
+ child = subprocess.Popen([sys.executable, "-m", "app.auto_index"], env=env,
90
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
91
+ deadline = time.monotonic() + MAX_SECONDS
92
+ try:
93
+ while child.poll() is None:
94
+ if _stop.wait(1) or time.monotonic() >= deadline:
95
+ break
96
+ finally:
97
+ if child.poll() is None:
98
+ child.terminate()
99
+ try:
100
+ child.wait(timeout=3)
101
+ except subprocess.TimeoutExpired:
102
+ child.kill()
103
+ child.wait()
104
+
105
+
106
+ def start():
107
+ global _worker
108
+ if os.environ.get("RLX_TEST_TMP") or os.environ.get("RLX_AUTO_INDEX", "1") != "1" or (_worker and _worker.is_alive()):
109
+ return
110
+ _stop.clear()
111
+ def watch():
112
+ while not _stop.is_set():
113
+ try:
114
+ _child()
115
+ except Exception as exc:
116
+ log.warning("Automatic catalog worker failed: %s", type(exc).__name__)
117
+ _stop.wait(60)
118
+ _worker = threading.Thread(target=watch, daemon=True, name="auto-index")
119
+ _worker.start()
120
+
121
+
122
+ def stop():
123
+ _stop.set()
124
+ if _worker:
125
+ _worker.join(timeout=5)
126
+
127
+
128
+ if __name__ == "__main__":
129
+ from . import runtime
130
+ runtime.hub_timeouts()
131
+ cycle()
app/catalog.py CHANGED
@@ -92,7 +92,7 @@ def hidden() -> set[str]:
92
  return set(settings.get("hidden", []))
93
 
94
 
95
- # FineEnvs' flagship environment servers lead Trending until an admin saves pins of their own (an empty list included)
96
  DEFAULT_PINNED = ["space:FineEnvs/geoguesser-env", "space:FineEnvs/latex-ocr-env"]
97
 
98
 
@@ -263,7 +263,9 @@ def _listing_fetch(full: bool) -> list[dict[str, Any]]:
263
  for d in _api().list_datasets(filter=tag, sort="trending_score", limit=None if full else 1000, expand=_EXPAND):
264
  if d.id not in rows and not d.private and not d.gated:
265
  rows[d.id] = _summary(d)
266
- except Exception: # noqa: BLE001 - one tag failing leaves the rest
 
 
267
  continue
268
  missing = [s for s in featured_datasets() if s not in rows]
269
  with ThreadPoolExecutor(max_workers=12) as pool: # one Hub call each: in parallel
@@ -361,9 +363,6 @@ def environments(include_hidden: bool = False) -> list[dict[str, Any]]:
361
  indexed = headline(r["id"]) if r["kind"] == "dataset" else None
362
  fw = "harbor" if indexed and indexed.get("tasks") else r.get("framework") # its index found task folders
363
  coll = collection_of(r["key"])
364
- if r["kind"] == "space" and coll == "fineenvs" and fw != "openenv": # curated as OpenEnv servers, manifest or not
365
- r = {**r, "openenv": True, "badges": ["OpenEnv"]}
366
- fw = "openenv"
367
  out.append({**r, "framework": fw, "collection": coll, "pinned": r["key"] in pins, "indexed": indexed})
368
  return out
369
 
@@ -374,12 +373,12 @@ _SPACE_EXPAND = ["likes", "trendingScore", "sdk", "runtime", "tags", "lastModifi
374
 
375
  def _space_summary(s: Any) -> dict[str, Any]:
376
  card = (s.card_data.to_dict() if s.card_data else {}) or {}
377
- raw = s.tags or []
378
  stage = getattr(s.runtime, "stage", None) if s.runtime is not None else None
379
  version = next((t[8:] for t in raw if re.match(r"^openenv-\d", t)), None)
380
  files = [f.rfilename for f in (getattr(s, "siblings", None) or [])]
381
  manifest = min((f for f in files if _MANIFEST.match(f) and not _VENDORED.match(f)), key=lambda f: f.count("/"), default=None)
382
- openenv = "openenv" in raw or bool(version) or manifest is not None
383
  ors = bool({"ors", "openreward"} & set(raw))
384
  return {"id": s.id, "key": f"space:{s.id}", "kind": "space", "openenv": openenv, "manifest": manifest,
385
  "framework": "openenv" if openenv else "ors" if ors else "space", "files": len(files) or None,
@@ -395,10 +394,10 @@ def _space_summary(s: Any) -> dict[str, Any]:
395
  "private": bool(s.private), "gated": False}
396
 
397
 
398
- SPACE_TAGS = ("openenv", "rl-environment", "ors", "openreward")
399
  # a manifest a Space's own environment declares (not one vendored from OpenEnv's source, or its templates)
400
  _MANIFEST = re.compile(r"^(?:[^/]+/)?openenv\.ya?ml$")
401
- _VENDORED = re.compile(r"^(src/(openenv|core)/|build/|.*\.egg-info/|envs/[^/]+/)")
402
 
403
 
404
  def spaces(full: bool = True) -> list[dict[str, Any]]:
@@ -415,12 +414,16 @@ def spaces(full: bool = True) -> list[dict[str, Any]]:
415
  if sp.private or sp.sdk != "docker" or "research-article-template" in tags:
416
  continue
417
  rows.setdefault(sp.id, _space_summary(sp))
418
- except Exception: # noqa: BLE001 - Spaces failing leaves the datasets
 
 
419
  continue
420
  for sid in featured:
421
  if sid not in rows:
422
  try:
423
- rows[sid] = _space_summary(_api().space_info(sid, expand=expand)) # with its files: its manifest
 
 
424
  except Exception: # noqa: BLE001
425
  continue
426
  return list(rows.values())
 
92
  return set(settings.get("hidden", []))
93
 
94
 
95
+ # Editorial picks, shown separately from the metric-based trending order.
96
  DEFAULT_PINNED = ["space:FineEnvs/geoguesser-env", "space:FineEnvs/latex-ocr-env"]
97
 
98
 
 
263
  for d in _api().list_datasets(filter=tag, sort="trending_score", limit=None if full else 1000, expand=_EXPAND):
264
  if d.id not in rows and not d.private and not d.gated:
265
  rows[d.id] = _summary(d)
266
+ except Exception: # noqa: BLE001 - only the quick bootstrap tolerates incomplete discovery
267
+ if full:
268
+ raise
269
  continue
270
  missing = [s for s in featured_datasets() if s not in rows]
271
  with ThreadPoolExecutor(max_workers=12) as pool: # one Hub call each: in parallel
 
363
  indexed = headline(r["id"]) if r["kind"] == "dataset" else None
364
  fw = "harbor" if indexed and indexed.get("tasks") else r.get("framework") # its index found task folders
365
  coll = collection_of(r["key"])
 
 
 
366
  out.append({**r, "framework": fw, "collection": coll, "pinned": r["key"] in pins, "indexed": indexed})
367
  return out
368
 
 
373
 
374
  def _space_summary(s: Any) -> dict[str, Any]:
375
  card = (s.card_data.to_dict() if s.card_data else {}) or {}
376
+ raw = [str(t).lower() for t in (s.tags or [])]
377
  stage = getattr(s.runtime, "stage", None) if s.runtime is not None else None
378
  version = next((t[8:] for t in raw if re.match(r"^openenv-\d", t)), None)
379
  files = [f.rfilename for f in (getattr(s, "siblings", None) or [])]
380
  manifest = min((f for f in files if _MANIFEST.match(f) and not _VENDORED.match(f)), key=lambda f: f.count("/"), default=None)
381
+ openenv = bool({"openenv", "library:openenv"} & set(raw)) or bool(version) or manifest is not None
382
  ors = bool({"ors", "openreward"} & set(raw))
383
  return {"id": s.id, "key": f"space:{s.id}", "kind": "space", "openenv": openenv, "manifest": manifest,
384
  "framework": "openenv" if openenv else "ors" if ors else "space", "files": len(files) or None,
 
394
  "private": bool(s.private), "gated": False}
395
 
396
 
397
+ SPACE_TAGS = ("openenv", "library:openenv", "rl-environment", "ors", "openreward")
398
  # a manifest a Space's own environment declares (not one vendored from OpenEnv's source, or its templates)
399
  _MANIFEST = re.compile(r"^(?:[^/]+/)?openenv\.ya?ml$")
400
+ _VENDORED = re.compile(r"^(src/|build/|dist/|templates?/|examples?/|tests?/|vendor/|\.venv/|.*\.egg-info/|envs/)")
401
 
402
 
403
  def spaces(full: bool = True) -> list[dict[str, Any]]:
 
414
  if sp.private or sp.sdk != "docker" or "research-article-template" in tags:
415
  continue
416
  rows.setdefault(sp.id, _space_summary(sp))
417
+ except Exception: # noqa: BLE001 - a full census must not silently publish partial counts
418
+ if full:
419
+ raise
420
  continue
421
  for sid in featured:
422
  if sid not in rows:
423
  try:
424
+ sp = _api().space_info(sid, expand=expand)
425
+ if not sp.private and sp.sdk == "docker" and "research-article-template" not in (sp.tags or []):
426
+ rows[sid] = _space_summary(sp)
427
  except Exception: # noqa: BLE001
428
  continue
429
  return list(rows.values())
app/community.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runner-independent filtering and honest summaries of saved public rollouts.
2
+
3
+ Visibility is checked by the API before records enter this module. Facets ignore
4
+ their own selection; headline counts always describe the filtered population.
5
+ Rewards are grouped by environment, runner, domain, metric, judge and revision.
6
+ There is no cross-environment model ranking.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections import Counter, defaultdict
12
+
13
+ DOMAINS = {"code": "Code", "webdev": "Webdev", "cyber": "Cyber", "music": "Music", "general": "General"}
14
+
15
+
16
+ def is_public(r, hidden=()):
17
+ return (r.get("visibility") == "public" and r.get("status") == "done" and scalar_reward(r)
18
+ and not r.get("restricted") and r.get("id") not in hidden)
19
+
20
+
21
+ def scalar_reward(r):
22
+ value = r.get("reward")
23
+ try:
24
+ return type(value) in (int, float) and math.isfinite(value)
25
+ except OverflowError:
26
+ return False
27
+
28
+
29
+ def runner(r):
30
+ value = r.get("runner") or ("mimo" if r.get("domain") in DOMAINS else "harbor")
31
+ return {"nemo": "nemo-gym", "nemogym": "nemo-gym", "nemo_gym": "nemo-gym"}.get(value, value)
32
+
33
+
34
+ def group(r):
35
+ return DOMAINS.get(r.get("domain")) or r.get("dataset") or "Unknown environment"
36
+
37
+
38
+ def served(r):
39
+ return "own endpoint" if r.get("endpoint") else r.get("provider") or "auto"
40
+
41
+
42
+ def band(r):
43
+ if r.get("status") != "done" or not scalar_reward(r):
44
+ return "unscored"
45
+ return "full" if r["reward"] >= .999 else "zero" if r["reward"] <= 0 else "partial"
46
+
47
+
48
+ VALUES = {"dataset": lambda r: r.get("dataset") or "", "group": group, "runner": runner,
49
+ "model": lambda r: r.get("model") or "custom endpoint", "served": served,
50
+ "judge": lambda r: r.get("judge") or "", "reward": band,
51
+ "thinking": lambda r: (r.get("params") or {}).get("thinking") or "default"}
52
+
53
+
54
+ def context(r):
55
+ return (r.get("dataset") or "Unknown environment", runner(r), r.get("domain") or "",
56
+ r.get("reward_key") or "reward", r.get("judge") or "",
57
+ r.get("sha") or (r.get("provenance") or {}).get("commit") or "")
58
+
59
+
60
+ def stats(runs):
61
+ groups = defaultdict(list)
62
+ for r in runs:
63
+ if r.get("status") == "done" and scalar_reward(r):
64
+ groups[(*context(r), VALUES["model"](r), served(r))].append(r["reward"])
65
+ results = [{"dataset": k[0], "runner": k[1], "domain": k[2], "metric": k[3], "judge": k[4], "revision": k[5],
66
+ "model": k[6], "served": k[7], "runs": len(v), "mean": math.fsum(x / len(v) for x in v), "min": min(v), "max": max(v)}
67
+ for k, v in sorted(groups.items())]
68
+ contexts = {context(r) for r in runs}
69
+ return {"runs": len(runs), "scored": sum(len(v) for v in groups.values()), "datasets": len({r.get("dataset") for r in runs}),
70
+ "tasks": len({(r.get("dataset"), r.get("path")) for r in runs}), "models": len({VALUES["model"](r) for r in runs}),
71
+ "runners": len({runner(r) for r in runs}), "comparable": len(contexts) == 1, "results": results}
72
+
73
+
74
+ def query(everyone, filters, *, sort="new", offset=0, limit=100):
75
+ def keep(r, skip=None):
76
+ if filters.get("path") is not None and r.get("path") != filters["path"]:
77
+ return False
78
+ words = str(filters.get("q") or "").lower().split()[:16]
79
+ haystack = " ".join(str(r.get(k) or "") for k in ("title", "task_id", "path", "dataset", "model", "runner", "adapter")).lower()
80
+ if not all(word in haystack for word in words):
81
+ return False
82
+ return all(not value or name == skip or VALUES[name](r) == value
83
+ for name, value in filters.items() if name in VALUES)
84
+ runs = [r for r in everyone if keep(r)]
85
+ # Stable explicit ordering also makes pagination deterministic for tied times.
86
+ runs.sort(key=lambda r: (-(r.get("created_at") or 0), r["id"]))
87
+ key = {"reward_desc": lambda r: -r["reward"], "reward_asc": lambda r: r["reward"],
88
+ "cost_asc": lambda r: (r.get("cost") or {}).get("total") or 0,
89
+ "cost_desc": lambda r: -((r.get("cost") or {}).get("total") or 0)}.get(sort)
90
+ if key:
91
+ runs.sort(key=key)
92
+ facets = {}
93
+ for name, value in VALUES.items():
94
+ counts = Counter(value(r) for r in everyone if keep(r, name) and value(r))
95
+ facets[name] = [[k, n] for k, n in sorted(counts.items(), key=lambda x: (-x[1], x[0]))]
96
+ summary = stats(runs)
97
+ return {"stats": summary, "facets": facets, "total": len(runs), "tasks": summary["tasks"],
98
+ "runs": runs[max(0, offset):max(0, offset) + max(1, min(limit, 500))]}
app/config.py CHANGED
@@ -23,6 +23,8 @@ OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface
23
  SPACE_HOST = os.environ.get("SPACE_HOST", "")
24
  PUBLIC_URL = (os.environ.get("RLX_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
25
  LOCAL_MODE = not OAUTH_CLIENT_ID
 
 
26
  # `jobs` runs the sandbox on the visitor's account, `inference-api` calls Inference Providers as them,
27
  # `read-repos` opens their private and gated datasets.
28
  OAUTH_SCOPES = os.environ.get("RLX_OAUTH_SCOPES", "openid profile inference-api jobs read-repos").split() # the admin Space asks for less
 
23
  SPACE_HOST = os.environ.get("SPACE_HOST", "")
24
  PUBLIC_URL = (os.environ.get("RLX_PUBLIC_URL") or (f"https://{SPACE_HOST}" if SPACE_HOST else "")).rstrip("/")
25
  LOCAL_MODE = not OAUTH_CLIENT_ID
26
+ # Enable only behind a proxy that replaces/appends the visitor's address.
27
+ TRUST_PROXY = os.environ.get("RLX_TRUST_PROXY", "1" if os.environ.get("SPACE_ID") else "0") == "1"
28
  # `jobs` runs the sandbox on the visitor's account, `inference-api` calls Inference Providers as them,
29
  # `read-repos` opens their private and gated datasets.
30
  OAUTH_SCOPES = os.environ.get("RLX_OAUTH_SCOPES", "openid profile inference-api jobs read-repos").split() # the admin Space asks for less
app/envs/capabilities.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Evidence-based capabilities shared by dataset adapters, live Spaces, UI and MCP.
2
+
3
+ A framework label describes a format, not permission or proof that it can run.
4
+ A dataset's native harness may be external; a live server may be asleep or may
5
+ speak HTTP instead of OpenEnv's WebSocket protocol. Keep those axes independent.
6
+ The contract is additive so older clients can keep using run.options/step_api.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import asdict, dataclass
11
+ from typing import Any
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Framework:
16
+ id: str
17
+ label: str
18
+ task: str
19
+ lifecycle: str
20
+ reward: str
21
+ docs: str
22
+
23
+
24
+ FRAMEWORKS = {
25
+ f.id: f for f in (
26
+ Framework("harbor", "Harbor", "Task folder or packed task files",
27
+ "Prepare sandbox → run agent → verify → collect artifacts",
28
+ "The task's verifier writes a scalar or named reward metrics.",
29
+ "https://www.harborframework.com/docs/tasks"),
30
+ Framework("openenv", "OpenEnv", "Live environment with typed actions and observations",
31
+ "Open session → reset → step until done → close",
32
+ "The environment returns rewards and termination with observations.",
33
+ "https://huggingface.co/docs/openenv/main/reference/core"),
34
+ Framework("verifiers", "Verifiers", "Rows loaded by an installed environment or taskset",
35
+ "Load environment/taskset → run its harness → score the rollout",
36
+ "The installed environment's rubric or judge defines the reward; rows alone do not include that runtime.",
37
+ "https://github.com/PrimeIntellect-ai/verifiers/tree/main/docs"),
38
+ Framework("nemo-gym", "NeMo Gym", "Task data plus agent and resources-server configuration",
39
+ "Seed → interact → verify, or reset → step for Gymnasium servers",
40
+ "A resources server or embedded agent harness computes the reward.",
41
+ "https://docs.nvidia.com/nemo/gym/build-verifiers/"),
42
+ Framework("verl", "verl", "Training rows with prompt and reward metadata",
43
+ "Generate responses → route to the configured reward function → train",
44
+ "data_source selects a scorer unless training config supplies a custom reward function.",
45
+ "https://verl.readthedocs.io/en/latest/advance/reward_loop.html"),
46
+ Framework("skyrl", "SkyRL", "Training rows routed to a skyrl-gym environment",
47
+ "Load rows → run environment interactions → compute training rewards",
48
+ "The selected environment and its configuration define the reward.",
49
+ "https://skyrl.readthedocs.io/en/latest/"),
50
+ Framework("mimo", "MiMo", "Tasks from the MiMo RL environment release",
51
+ "Prepare task → run the release's domain harness → grade",
52
+ "Each domain uses its own verifier; supported conversions also run through Harbor.",
53
+ "https://huggingface.co/datasets/XiaomiMiMo/MiMo-V2.6-RL-oss"),
54
+ Framework("gymnasium", "Gymnasium HTTP", "A server exposing reset and step over HTTP",
55
+ "Reset → step until terminated or truncated → close",
56
+ "The server returns step rewards; its HTTP schema defines the request and response.",
57
+ "https://gymnasium.farama.org/api/env/"),
58
+ Framework("mcp", "MCP", "Tools exposed by a live server", "Connect → call advertised tools → close",
59
+ "A reward is available only when the server exposes a grader or returns one.",
60
+ "https://modelcontextprotocol.io/specification/2025-06-18/server/tools"),
61
+ Framework("ors", "ORS", "A server with named environments and sessions",
62
+ "Create session → call environment → close session", "The server defines its grading contract.", ""),
63
+ Framework("api", "HTTP API", "A live server's published operations", "Call the server's documented routes",
64
+ "Inspect the published schema and grading routes for this server.", ""),
65
+ Framework("rows", "RL dataset", "Dataset rows", "Browse task data; use the source's environment to execute it",
66
+ "A dataset label or answer column alone does not establish a runnable grader.", ""),
67
+ )
68
+ }
69
+ HOSTED_RUNNERS = frozenset({"harbor", "mimo", "nemo-gym"})
70
+
71
+
72
+ def framework(value: str | None) -> Framework:
73
+ key = str(value or "").lower().replace("_", "-").replace(" ", "-")
74
+ aliases = {"nemo": "nemo-gym", "nemogym": "nemo-gym", "generic": "rows", "rl": "rows", "harbor-(packed)": "harbor"}
75
+ key = aliases.get(key, key)
76
+ if key.startswith("mimo"):
77
+ key = "mimo"
78
+ return FRAMEWORKS.get(key, FRAMEWORKS["rows"])
79
+
80
+
81
+ def capability(id: str, label: str, state: str, detail: str) -> dict[str, str]:
82
+ if state not in {"available", "external", "unavailable", "unknown"}:
83
+ raise ValueError(f"invalid capability state: {state}")
84
+ return {"id": id, "label": label, "state": state, "detail": detail}
85
+
86
+
87
+ def dataset_support(name: str, *, state: str = "ready", options: list[dict[str, Any]] | None = None,
88
+ note: str = "", revision: str | None = None, restricted: bool = False) -> dict[str, Any]:
89
+ f = framework(name)
90
+ ready = state == "ready"
91
+ features = [capability("browse", "Browse tasks", "available" if ready else "unknown",
92
+ "Inspect the prompt, grading information and source files." if ready else "Task discovery is still in progress.")]
93
+ if options is None:
94
+ features.append(capability("run", "Run an agent", "unknown", "Open a task to check its harness and runtime requirements."))
95
+ else:
96
+ runnable = [o for o in options if o.get("ok") and o.get("runner") in HOSTED_RUNNERS and not o.get("href")]
97
+ external = [o for o in options if o.get("href") or (not o.get("ok") and o.get("runner") in {"verifiers", "nemo-gym", "nemogym", "verl", "skyrl"})]
98
+ why = "; ".join(dict.fromkeys(o.get("why") for o in options if o.get("why")))
99
+ features.append(capability("run", "Run an agent", "available" if runnable else "external" if external else "unavailable",
100
+ "Runs here with " + ", ".join(o["label"] for o in runnable) + "." if runnable else
101
+ why or note or "This source has no executable harness configured here."))
102
+ features.append(capability("mcp", "Explore with an agent", "unavailable" if restricted else "available" if ready else "unknown",
103
+ "The MCP bridge only serves public datasets; this source requires your sign-in." if restricted else
104
+ "MCP exposes public task browsing. Execution follows the runtime support above."))
105
+ return {"version": 1, "framework": asdict(f), "source": "dataset", "evidence": "adapter", "revision": revision,
106
+ "transport": None, "capabilities": features}
107
+
108
+
109
+ def space_support(info: dict[str, Any]) -> dict[str, Any]:
110
+ up = bool(info.get("running"))
111
+ f = framework(info.get("framework") or "api")
112
+ transport = "openenv-ws" if info.get("openenv") else "http" if info.get("api") else "mcp-http" if info.get("mcp") else None
113
+ can_play = bool(info.get("step_api") or info.get("mcp") or info.get("api"))
114
+ status = "available" if up and can_play else "unavailable" if up else "unknown"
115
+ detail = ("Play an episode over OpenEnv's WebSocket session." if transport == "openenv-ws" else
116
+ "Call the published HTTP routes; each explorer session keeps its own cookies." if transport == "http" else
117
+ "Call MCP tools. Episode state depends on the server's MCP implementation.") if can_play else "No supported interactive API was discovered."
118
+ if not up:
119
+ detail = "The Space is " + str(info.get("stage") or "unreachable").lower().replace("_", " ") + "; check its live interface when it is running."
120
+ return {"version": 1, "framework": asdict(f), "source": "space", "evidence": "live" if up else "unverified",
121
+ "transport": transport, "capabilities": [
122
+ capability("play", "Interactive playground", status, detail),
123
+ capability("browse", "Browse tasks", "available" if up and info.get("task_api") else "unavailable" if up else "unknown",
124
+ "Tasks are served by the environment's Task API." if info.get("task_api") else "No live Task API was discovered; source files may describe the tasks."),
125
+ capability("mcp", "Connect an agent", status,
126
+ "The explorer bridges the advertised interface to MCP." if up and can_play else detail),
127
+ ]}
app/envs/mimo.py CHANGED
@@ -250,7 +250,7 @@ class MiMoAdapter(c.Adapter):
250
  if not rows and not how:
251
  return None
252
  net = ("Web search and fetch tools are off." if d == "webdev" else "Web search and fetch tools are off, and the sites where answers live "
253
- "(code hosting, bug trackers, search engines) are unreachable from the sandbox; everything else, including local services, works as usual.")
254
  return c.section("setup", "How the sandbox is set up", [c.kv(rows), c.note(f"{how} {net}", "shield") if how else None], icon="box")
255
 
256
  def _glance(self, v: dict[str, Any]) -> list[list[str]]:
 
250
  if not rows and not how:
251
  return None
252
  net = ("Web search and fetch tools are off." if d == "webdev" else "Web search and fetch tools are off, and the sites where answers live "
253
+ "(code hosting, bug trackers, search engines) are added to a hosts-file blocklist. This reduces answer leakage but is not an egress firewall. Other connections depend on the hosting provider's network policy.")
254
  return c.section("setup", "How the sandbox is set up", [c.kv(rows), c.note(f"{how} {net}", "shield") if how else None], icon="box")
255
 
256
  def _glance(self, v: dict[str, Any]) -> list[list[str]]:
app/envs/nemo_gym_build.py CHANGED
@@ -18,6 +18,12 @@ from pathlib import Path
18
 
19
  import yaml
20
 
 
 
 
 
 
 
21
 
22
  def main(ROOT: Path) -> None:
23
  OUT = CAT = Path(__file__).with_name("nemo_gym_catalog.json")
@@ -93,6 +99,8 @@ def main(ROOT: Path) -> None:
93
  continue
94
  t = table.get(d.name, {})
95
  servers[d.name] = {k: v for k, v in {"description": t.get("description"), "verification": t.get("verification"), "readme": readme_para(d)}.items() if v}
 
 
96
  for cfg in sorted((d / "configs").glob("*.yaml")):
97
  flavor = d.name if cfg.stem == d.name else f"{d.name}/{cfg.stem}"
98
  scan(cfg, d.name, flavor, None)
 
18
 
19
  import yaml
20
 
21
+ # Reviewed against each server's implementation; the upstream environment table
22
+ # incorrectly calls these LLM judges. Keep regenerated catalogs accurate too.
23
+ VERIFICATION_OVERRIDES = {
24
+ "instruction_following": "Rule-based instruction checks",
25
+ "math_proof_judgement": "Deterministic answer parsing",
26
+ }
27
 
28
  def main(ROOT: Path) -> None:
29
  OUT = CAT = Path(__file__).with_name("nemo_gym_catalog.json")
 
99
  continue
100
  t = table.get(d.name, {})
101
  servers[d.name] = {k: v for k, v in {"description": t.get("description"), "verification": t.get("verification"), "readme": readme_para(d)}.items() if v}
102
+ if d.name in VERIFICATION_OVERRIDES:
103
+ servers[d.name]["verification"] = VERIFICATION_OVERRIDES[d.name]
104
  for cfg in sorted((d / "configs").glob("*.yaml")):
105
  flavor = d.name if cfg.stem == d.name else f"{d.name}/{cfg.stem}"
106
  scan(cfg, d.name, flavor, None)
app/envs/nemo_gym_catalog.json CHANGED
@@ -1985,7 +1985,7 @@
1985
  },
1986
  "instruction_following": {
1987
  "description": "IFEval and IFBench-style instruction following",
1988
- "verification": "LLM judge",
1989
  "readme": "Evaluates language model responses against instruction-following criteria using Open-Instruct and IFEval constraints.",
1990
  "fields": {
1991
  "id": {
@@ -2454,7 +2454,7 @@
2454
  },
2455
  "math_proof_judgement": {
2456
  "description": "Binary proof judgement — model reads a problem and proof, outputs Yes/No",
2457
- "verification": "LLM judge",
2458
  "readme": "Binary judgement of math proofs. The policy model reads a problem + candidate proof and outputs `Judgement: Yes` (proof correct) or `Judgement: No` (proof incorrect). Verification is fully deterministic — a single regex parses the final judgement — so **no external LLM judge is called**. This mirrors NeMo Skills' `answer-judgement` metric (`is_correct_judgement` format 1).",
2459
  "fields": {
2460
  "expected_judgement": {
 
1985
  },
1986
  "instruction_following": {
1987
  "description": "IFEval and IFBench-style instruction following",
1988
+ "verification": "Rule-based instruction checks",
1989
  "readme": "Evaluates language model responses against instruction-following criteria using Open-Instruct and IFEval constraints.",
1990
  "fields": {
1991
  "id": {
 
2454
  },
2455
  "math_proof_judgement": {
2456
  "description": "Binary proof judgement — model reads a problem and proof, outputs Yes/No",
2457
+ "verification": "Deterministic answer parsing",
2458
  "readme": "Binary judgement of math proofs. The policy model reads a problem + candidate proof and outputs `Judgement: Yes` (proof correct) or `Judgement: No` (proof incorrect). Verification is fully deterministic — a single regex parses the final judgement — so **no external LLM judge is called**. This mirrors NeMo Skills' `answer-judgement` metric (`is_correct_judgement` format 1).",
2459
  "fields": {
2460
  "expected_judgement": {
app/envs/nemogym.py CHANGED
@@ -250,7 +250,8 @@ def run_blocks(spec: str, config: str, split: str, agents: list[dict[str, Any]],
250
  artifact = next((u.get("artifact") for u in cat["datasets"].get(spec.lower()) or [] if u.get("type") == split and u.get("artifact")), None) \
251
  if config == "default" else None
252
  what = f"this dataset's {split} split" + ("" if config == "default" else f" of {config}")
253
- lines = ["# NeMo Gym, from source (Python 3.13+, uv)", "git clone https://github.com/NVIDIA-NeMo/Gym.git && cd Gym",
 
254
  "uv venv --python 3.13.14 && source .venv/bin/activate && uv sync", "",
255
  f"# the rows: {what}, as JSON Lines" + (", and this row alone" if row is not None else ""), *_download(spec, config, split, local, artifact,
256
  rowfiles.files_of(spec, meta or {}, config, split) if config != "default" else [])]
@@ -292,8 +293,21 @@ def run_blocks(spec: str, config: str, split: str, agents: list[dict[str, Any]],
292
  return blocks
293
 
294
 
295
- def run_option(agent: dict[str, Any]) -> dict[str, Any]:
296
- """Not here: the run panel says why in one sentence and points at the commands on the page."""
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  rs = agent.get("resources_server")
298
  return c.run_option("nemo-gym", "NeMo Gym", ok=False,
299
  why=f"it is scored by NeMo Gym's {rs + ' ' if rs else ''}resources server, which runs only inside NeMo Gym",
@@ -430,8 +444,9 @@ class NemoGym(Processor):
430
  rows = convo.field_rows({**dict(settings), **nested})
431
  sections.append(section("request", "Request settings", [c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))], kind="blocks",
432
  note="the rest of responses_create_params"))
 
433
  sections.append(section("run", "Run it with NeMo Gym", run_blocks(ds.spec, config, split, [agent], row=i, meta=ds.card), kind="blocks",
434
- note="it doesn't run in this app"))
435
  rest = {k: v for k, v in clean.items() if k not in task_data and k not in FRAMEWORK and v not in (None, "", [], {})}
436
  if rest:
437
  label = "provenance and metrics" if roles_of else "the rest of the row"
@@ -452,7 +467,7 @@ class NemoGym(Processor):
452
  glance.append(["Pass rate", p])
453
  card = self.card(row, i, roles)
454
  return {"title": title[:200], "id": card["id"], "chips": card["chips"], "sections": sections, "withheld": gone, "glance": glance,
455
- "framework_run": run_option(agent),
456
  "summary": "Scored on the next action matching the expert's" if pivot else
457
  (f"Verifier: {agent['verification'].lower()}" if agent.get("verification") else "Scored by its resources server")}
458
 
 
250
  artifact = next((u.get("artifact") for u in cat["datasets"].get(spec.lower()) or [] if u.get("type") == split and u.get("artifact")), None) \
251
  if config == "default" else None
252
  what = f"this dataset's {split} split" + ("" if config == "default" else f" of {config}")
253
+ lines = ["# NeMo Gym, from the revision used by this explorer's catalog (Python 3.13+, uv)",
254
+ "git clone https://github.com/NVIDIA-NeMo/Gym.git && cd Gym", f"git checkout {cat['commit']}",
255
  "uv venv --python 3.13.14 && source .venv/bin/activate && uv sync", "",
256
  f"# the rows: {what}, as JSON Lines" + (", and this row alone" if row is not None else ""), *_download(spec, config, split, local, artifact,
257
  rowfiles.files_of(spec, meta or {}, config, split) if config != "default" else [])]
 
293
  return blocks
294
 
295
 
296
+ def run_option(agent: dict[str, Any], row: dict | None = None) -> dict[str, Any]:
297
+ from .. import nemo_runner
298
+ if row is not None and nemo_runner.supported(agent):
299
+ try:
300
+ nemo_runner.validate(row, agent)
301
+ except (ValueError, KeyError, TypeError) as error:
302
+ return c.run_option("nemo-gym", "NeMo Gym prediction", ok=False, why=str(error), endpoint=False, sandbox=False)
303
+ return c.run_option("nemo-gym", "NeMo Gym prediction", ok=True, endpoint=False, sandbox=False,
304
+ about="One model prediction, scored by NVIDIA's NeMo Gym argument comparator. Tool calls are predictions, not executed actions. No sandbox is needed.",
305
+ notes=[f"Verifier pinned to NVIDIA-NeMo/Gym `{nemo_runner.REVISION[:12]}` with its native 0.1 word-similarity threshold."] +
306
+ (["Some source tools have invalid strict schemas. Their parameters stay unchanged; strict enforcement is disabled so HF providers accept them."] if nemo_runner.relaxed_tools(row) else []),
307
+ harnesses=["opencode"], fields=[
308
+ c.field("max_tokens", "Max output tokens", "number", default=2048, min=256, max=8192, advanced=True),
309
+ c.field("temperature", "Temperature", "number", min=0, max=2, step=0.1, advanced=True)],
310
+ estimate={"tokens_in": 4000, "tokens_out": 1000, "minutes": 0})
311
  rs = agent.get("resources_server")
312
  return c.run_option("nemo-gym", "NeMo Gym", ok=False,
313
  why=f"it is scored by NeMo Gym's {rs + ' ' if rs else ''}resources server, which runs only inside NeMo Gym",
 
444
  rows = convo.field_rows({**dict(settings), **nested})
445
  sections.append(section("request", "Request settings", [c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))], kind="blocks",
446
  note="the rest of responses_create_params"))
447
+ option = run_option(agent, row)
448
  sections.append(section("run", "Run it with NeMo Gym", run_blocks(ds.spec, config, split, [agent], row=i, meta=ds.card), kind="blocks",
449
+ note="Native CLI alternative; prediction runs are also available here" if option["ok"] else "This task needs the native runtime"))
450
  rest = {k: v for k, v in clean.items() if k not in task_data and k not in FRAMEWORK and v not in (None, "", [], {})}
451
  if rest:
452
  label = "provenance and metrics" if roles_of else "the rest of the row"
 
467
  glance.append(["Pass rate", p])
468
  card = self.card(row, i, roles)
469
  return {"title": title[:200], "id": card["id"], "chips": card["chips"], "sections": sections, "withheld": gone, "glance": glance,
470
+ "framework_run": option,
471
  "summary": "Scored on the next action matching the expert's" if pivot else
472
  (f"Verifier: {agent['verification'].lower()}" if agent.get("verification") else "Scored by its resources server")}
473
 
app/envs/registry.py CHANGED
@@ -16,6 +16,7 @@ from typing import Any, Callable
16
 
17
  from .. import catalog
18
  from .contract import Adapter, Env
 
19
  from .harbor import HarborAdapter
20
  from .mimo import MiMoAdapter, harbor_aliases, harbor_to_mimo
21
  from .rows import RowsAdapter
@@ -86,8 +87,10 @@ def summary(spec: str, token: str | None = None, subset: str | None = None) -> d
86
  env = resolve("dataset", spec, token)
87
  s = env.adapter.summary(env, subset)
88
  how = s.get("how") or {}
 
89
  return {"env": {**describe_env(env), "framework": how.get("framework") or env.adapter.framework},
90
- "info": {k: v for k, v in env.meta.items() if k != "all_tags"}, "collection": catalog.collection(spec), **s}
 
91
 
92
 
93
  def tasks(spec: str, token: str | None = None, **kw: Any) -> dict[str, Any]:
@@ -108,6 +111,7 @@ def task(spec: str, ref: str, token: str | None = None) -> dict[str, Any]:
108
  out.setdefault("collection", catalog.collection(spec))
109
  out["env"] = {**describe_env(env), "framework": view.get("framework") or env.adapter.framework}
110
  out["run"] = {"options": options, "note": "" if options else env.adapter.run_note}
 
111
  out["aliases"] = [{"env": k, "ref": r} for k, r in aliases(env, ref)]
112
  return out
113
 
 
16
 
17
  from .. import catalog
18
  from .contract import Adapter, Env
19
+ from .capabilities import dataset_support
20
  from .harbor import HarborAdapter
21
  from .mimo import MiMoAdapter, harbor_aliases, harbor_to_mimo
22
  from .rows import RowsAdapter
 
87
  env = resolve("dataset", spec, token)
88
  s = env.adapter.summary(env, subset)
89
  how = s.get("how") or {}
90
+ name = how.get("framework") or env.adapter.framework
91
  return {"env": {**describe_env(env), "framework": how.get("framework") or env.adapter.framework},
92
+ "info": {k: v for k, v in env.meta.items() if k != "all_tags"}, "collection": catalog.collection(spec), **s,
93
+ "support": dataset_support(name, state=s.get("state", "ready"), revision=env.meta.get("sha"), restricted=bool(env.meta.get("restricted")))}
94
 
95
 
96
  def tasks(spec: str, token: str | None = None, **kw: Any) -> dict[str, Any]:
 
111
  out.setdefault("collection", catalog.collection(spec))
112
  out["env"] = {**describe_env(env), "framework": view.get("framework") or env.adapter.framework}
113
  out["run"] = {"options": options, "note": "" if options else env.adapter.run_note}
114
+ out["support"] = dataset_support(out["env"]["framework"], options=options, note=env.adapter.run_note, revision=env.meta.get("sha"), restricted=bool(env.meta.get("restricted")))
115
  out["aliases"] = [{"env": k, "ref": r} for k, r in aliases(env, ref)]
116
  return out
117
 
app/envs/rows.py CHANGED
@@ -635,6 +635,16 @@ class RowsAdapter(c.Adapter):
635
 
636
  config, split, i = parse_ref(ref)
637
  view = self.task(env, ref)
 
 
 
 
 
 
 
 
 
 
638
  if view.get("_packed_run"):
639
  p = packed(env.id, config, split, i, env.token)
640
  return {"title": p["title"], "restricted": p["restricted"], "sha": p["sha"], "bytes": p["bytes"], "runnable": p["runnable"],
 
635
 
636
  config, split, i = parse_ref(ref)
637
  view = self.task(env, ref)
638
+ if (view.get("_framework_run") or {}).get("runner") == "nemo-gym":
639
+ from .nemogym import agent_of
640
+ from .. import nemo_runner
641
+ *_, row, truncated = _row(env.id, config, split, i, env.token)
642
+ if truncated:
643
+ raise ValueError("This task row is truncated; use the complete dataset with NeMo Gym")
644
+ agent = agent_of(row, env.id)
645
+ nemo_runner.validate(row, agent)
646
+ return {"title": view["title"], "restricted": bool(env.meta.get("restricted")), "sha": env.meta.get("sha"),
647
+ "native_task": {"row": row, "agent": agent}}
648
  if view.get("_packed_run"):
649
  p = packed(env.id, config, split, i, env.token)
650
  return {"title": p["title"], "restricted": p["restricted"], "sha": p["sha"], "bytes": p["bytes"], "runnable": p["runnable"],
app/envs/tools.py CHANGED
@@ -49,6 +49,7 @@ def call(spec: str, name: str, args: dict[str, Any]) -> Any:
49
  if s.get("state") != "ready":
50
  return {"state": s.get("state"), "note": "Its index is being built; try again in a minute.", "progress": s.get("progress")}
51
  return {"id": spec, "format": s["env"]["framework"], "read_as": (s.get("how") or {}).get("name"), "about": (s.get("how") or {}).get("about"),
 
52
  "tasks": s.get("total"), "subsets": [x["id"] for x in s.get("subsets") or []], "subset": s.get("subset"),
53
  "facets": {f["key"]: f["label"] for f in s.get("facets") or []},
54
  "overview": blocks_text([b for sec in s.get("overview") or [] for b in [{"type": "markdown", "text": f"## {sec['title']}"}, *sec["blocks"]]])}
@@ -101,6 +102,10 @@ def task_text(v: dict[str, Any]) -> str:
101
  if v.get("withheld"):
102
  out.append("\nWithheld, as they hold the answer: " + ", ".join(v["withheld"]))
103
  opts = (v.get("run") or {}).get("options") or []
 
 
 
 
104
  out.append("\n## Running it\n" + ("\n".join(f"- {o['label']}: " + ("runs here" if o["ok"] else f"can't run here ({o['why']})") for o in opts)
105
  or (v.get("run") or {}).get("note") or ""))
106
  for link in v.get("links") or []:
 
49
  if s.get("state") != "ready":
50
  return {"state": s.get("state"), "note": "Its index is being built; try again in a minute.", "progress": s.get("progress")}
51
  return {"id": spec, "format": s["env"]["framework"], "read_as": (s.get("how") or {}).get("name"), "about": (s.get("how") or {}).get("about"),
52
+ "support": s.get("support"),
53
  "tasks": s.get("total"), "subsets": [x["id"] for x in s.get("subsets") or []], "subset": s.get("subset"),
54
  "facets": {f["key"]: f["label"] for f in s.get("facets") or []},
55
  "overview": blocks_text([b for sec in s.get("overview") or [] for b in [{"type": "markdown", "text": f"## {sec['title']}"}, *sec["blocks"]]])}
 
102
  if v.get("withheld"):
103
  out.append("\nWithheld, as they hold the answer: " + ", ".join(v["withheld"]))
104
  opts = (v.get("run") or {}).get("options") or []
105
+ support = v.get("support") or {}
106
+ if support.get("framework"):
107
+ f = support["framework"]
108
+ out.append(f"\nLifecycle: {f['lifecycle']}\nReward: {f['reward']}")
109
  out.append("\n## Running it\n" + ("\n".join(f"- {o['label']}: " + ("runs here" if o["ok"] else f"can't run here ({o['why']})") for o in opts)
110
  or (v.get("run") or {}).get("note") or ""))
111
  for link in v.get("links") or []:
app/envs/verifiers_rows.py CHANGED
@@ -196,8 +196,11 @@ def _prompt_turns(row: dict[str, Any], env: dict[str, Any]) -> list[dict[str, An
196
  ts = [{"kind": "message", "role": "user", "text": row["question"]}]
197
  else:
198
  ts = []
199
- if env.get("system") and not any(t["role"] == "system" for t in ts):
200
- ts.insert(0, {"kind": "message", "role": "system", "text": env["system"]})
 
 
 
201
  return ts
202
 
203
 
@@ -308,7 +311,7 @@ class Verifiers(Processor):
308
  gblocks += [c.note("`info`, which the rewards also get:", "list"), c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))]
309
  sections.append(section("grading", "How it's graded", gblocks, kind="blocks", note="the environment's rewards"))
310
  sections.append(section("run", "Run it with Verifiers", run_blocks(ds.spec, env, config, split, row=i), kind="blocks", note="it doesn't run in this app"))
311
- shown = {"problem_statement", "prompt", "question", "info", "task", *IMAGE_COLS, "repo", "base_commit", "parent_commit", "workdir", "language",
312
  "repo_language", "version", "test_cmd", "install_config"}
313
  rest = {k: v for k, v in clean.items() if k not in shown and v not in (None, "", [], {})}
314
  if rest:
@@ -361,10 +364,10 @@ class Verifiers(Processor):
361
 
362
 
363
  def run_blocks(spec: str, env: dict[str, Any], config: str, split: str, row: int | None = None) -> list[dict[str, Any]]:
364
- """Commands that evaluate these rows with Verifiers 0.3.1 (the current release) and the Prime CLI."""
365
  eid = env.get("env")
366
  head = ["# Verifiers (pip: verifiers 0.3.1) and the Prime CLI, in a new uv project",
367
- "uv init vf-eval-here && cd vf-eval-here && uv add verifiers", "uv tool install prime"]
368
  if not eid:
369
  lines = head + ["", "# find and install the environment that loads this dataset (its card or repository names it)",
370
  "prime env list", "prime env install <owner>/<environment>", "",
 
196
  ts = [{"kind": "message", "role": "user", "text": row["question"]}]
197
  else:
198
  ts = []
199
+ # v1 TaskData carries an explicit system_prompt. It belongs in the agent's
200
+ # conversation, ahead of the row's prompt, and overrides a known v0 default.
201
+ system = row.get("system_prompt") if isinstance(row.get("system_prompt"), str) else env.get("system")
202
+ if system and not any(t["role"] == "system" for t in ts):
203
+ ts.insert(0, {"kind": "message", "role": "system", "text": system})
204
  return ts
205
 
206
 
 
311
  gblocks += [c.note("`info`, which the rewards also get:", "list"), c.custom("rl", "fields", {"rows": rows}, convo.fields_text(rows))]
312
  sections.append(section("grading", "How it's graded", gblocks, kind="blocks", note="the environment's rewards"))
313
  sections.append(section("run", "Run it with Verifiers", run_blocks(ds.spec, env, config, split, row=i), kind="blocks", note="it doesn't run in this app"))
314
+ shown = {"problem_statement", "prompt", "system_prompt", "question", "info", "task", *IMAGE_COLS, "repo", "base_commit", "parent_commit", "workdir", "language",
315
  "repo_language", "version", "test_cmd", "install_config"}
316
  rest = {k: v for k, v in clean.items() if k not in shown and v not in (None, "", [], {})}
317
  if rest:
 
364
 
365
 
366
  def run_blocks(spec: str, env: dict[str, Any], config: str, split: str, row: int | None = None) -> list[dict[str, Any]]:
367
+ """Commands targeting the release that exposes both the v0 and v1 CLIs."""
368
  eid = env.get("env")
369
  head = ["# Verifiers (pip: verifiers 0.3.1) and the Prime CLI, in a new uv project",
370
+ "uv init vf-eval-here && cd vf-eval-here && uv add 'verifiers==0.3.1'", "uv tool install prime"]
371
  if not eid:
372
  lines = head + ["", "# find and install the environment that loads this dataset (its card or repository names it)",
373
  "prime env list", "prime env install <owner>/<environment>", "",
app/http.py CHANGED
@@ -6,6 +6,7 @@ from __future__ import annotations
6
  import os
7
  import threading
8
  import time
 
9
 
10
  from fastapi import FastAPI, Request
11
  from fastapi.responses import JSONResponse
@@ -21,7 +22,7 @@ CSP = ("default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-sr
21
  def client_ip(request: Request) -> str:
22
  """The visitor's address: the last X-Forwarded-For entry is the one the Space's proxy added (earlier ones are
23
  whatever the client claimed)."""
24
- fwd = [p.strip() for p in request.headers.get("x-forwarded-for", "").split(",") if p.strip()]
25
  return fwd[-1] if fwd else (request.client.host if request.client else "?")
26
 
27
 
@@ -51,9 +52,22 @@ class Limiter:
51
 
52
 
53
  def same_origin(request: Request) -> bool:
 
54
  origin = request.headers.get("origin")
55
- host = request.headers.get("x-forwarded-host") or request.headers.get("host")
56
- return not origin or origin.split("://", 1)[-1] == host
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
 
59
  def install(app: FastAPI) -> None:
@@ -61,7 +75,11 @@ def install(app: FastAPI) -> None:
61
  async def _revalidate(request: Request, call_next):
62
  """Code and styles revalidate on every load (cheap: ETag -> 304), so a deploy is never half-cached."""
63
  resp = await call_next(request)
64
- if not request.url.path.startswith("/api/"):
 
 
 
 
65
  resp.headers.setdefault("Cache-Control", "no-cache")
66
  return resp
67
 
@@ -88,10 +106,6 @@ def install(app: FastAPI) -> None:
88
  """The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
89
  request must prove it came from this page: another site could otherwise start rollouts billed to you."""
90
  if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
91
- origin = request.headers.get("origin")
92
- host = request.headers.get("x-forwarded-host") or request.headers.get("host")
93
- if origin and origin.split("://", 1)[-1] != host:
94
- return JSONResponse({"detail": "cross-site request refused"}, 403)
95
- if not origin and request.headers.get("sec-fetch-site") not in (None, "same-origin", "none"):
96
  return JSONResponse({"detail": "cross-site request refused"}, 403)
97
  return await call_next(request)
 
6
  import os
7
  import threading
8
  import time
9
+ from urllib.parse import urlsplit
10
 
11
  from fastapi import FastAPI, Request
12
  from fastapi.responses import JSONResponse
 
22
  def client_ip(request: Request) -> str:
23
  """The visitor's address: the last X-Forwarded-For entry is the one the Space's proxy added (earlier ones are
24
  whatever the client claimed)."""
25
+ fwd = [p.strip() for p in request.headers.get("x-forwarded-for", "").split(",") if p.strip()] if config.TRUST_PROXY else []
26
  return fwd[-1] if fwd else (request.client.host if request.client else "?")
27
 
28
 
 
52
 
53
 
54
  def same_origin(request: Request) -> bool:
55
+ """Compare complete origins; forwarding headers supplied by a client aren't authority."""
56
  origin = request.headers.get("origin")
57
+ if not origin:
58
+ return request.headers.get("sec-fetch-site") in (None, "same-origin", "none")
59
+
60
+ def canonical(value: str):
61
+ try:
62
+ u = urlsplit(value)
63
+ if u.scheme not in ("http", "https") or not u.hostname or u.username or u.password or u.path or u.query or u.fragment:
64
+ return None
65
+ return u.scheme, u.hostname.lower(), u.port or (443 if u.scheme == "https" else 80)
66
+ except ValueError:
67
+ return None
68
+
69
+ expected = canonical(config.PUBLIC_URL) if config.PUBLIC_URL else canonical(f"{request.url.scheme}://{request.headers.get('host', '')}")
70
+ return expected is not None and canonical(origin) == expected
71
 
72
 
73
  def install(app: FastAPI) -> None:
 
75
  async def _revalidate(request: Request, call_next):
76
  """Code and styles revalidate on every load (cheap: ETag -> 304), so a deploy is never half-cached."""
77
  resp = await call_next(request)
78
+ if request.url.path.startswith(("/api/", "/mcp/", "/login")):
79
+ # APIs can contain a visitor's private dataset, run, or account. A shared
80
+ # proxy or the browser's cache must never reuse them for another visitor.
81
+ resp.headers["Cache-Control"] = "private, no-store"
82
+ else:
83
  resp.headers.setdefault("Cache-Control", "no-cache")
84
  return resp
85
 
 
106
  """The session cookie is SameSite=None (it has to work in the huggingface.co iframe), so a state-changing
107
  request must prove it came from this page: another site could otherwise start rollouts billed to you."""
108
  if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"):
109
+ if not same_origin(request):
 
 
 
 
110
  return JSONResponse({"detail": "cross-site request refused"}, 403)
111
  return await call_next(request)
app/indexer.py CHANGED
@@ -231,6 +231,9 @@ def refresh_indexes(items: list[dict[str, Any]], state: dict[str, Any], *, budge
231
  th.join(timeout=max(60.0, deadline - time.time() + 120))
232
  if th.is_alive():
233
  out["abandoned"].append(spec)
 
 
 
234
  _ev("build_abandoned", spec=spec, seconds=round(time.time() - t0))
235
  continue
236
  if "error" in result:
@@ -318,7 +321,7 @@ def prune(store, keep: int) -> list[str]:
318
 
319
  def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 1800, max_builds: int = 100, keep: int = 5,
320
  min_ratio: float = 0.8, force: bool = False, index: bool = True, dry_run: bool = False,
321
- out: Path | None = None) -> dict[str, Any]:
322
  """One indexer run (see the module docstring). `rows` stands in for the Hub listing (tests). Raises IndexerError
323
  (or anything else) without publishing."""
324
  from . import catalog, config, snapshot
@@ -330,17 +333,18 @@ def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 18
330
  _ev("pointer_unreadable", error=str(e))
331
  prev = None
332
  rows = rows if rows is not None else fetch_listing()
 
333
  rows = [r for r in rows if not r.get("private") and not r.get("gated")]
334
  if not force:
335
  guard(rows, prev, min_ratio)
336
  # catalog's own readers (environments, featured lookups) see this listing, and the app's fallback path finds it
337
  with catalog._listing_lock:
338
- catalog._env_list.update(rows=rows, at=time.time(), full=True)
339
  if not dry_run:
340
  import gzip
341
 
342
  catalog.atomic_write(config.STORAGE_DIR / "listing.json.gz",
343
- gzip.compress(json.dumps({"at": time.time(), "full": True, "rows": rows}, default=str).encode()))
344
  report: dict[str, Any] = {"listing": len(rows)}
345
  state = load_state()
346
  if index and not dry_run:
 
231
  th.join(timeout=max(60.0, deadline - time.time() + 120))
232
  if th.is_alive():
233
  out["abandoned"].append(spec)
234
+ # A bounded automatic worker exits after publication. Avoid retrying
235
+ # the same oversized build at the front of every subsequent batch.
236
+ state["failed"][spec] = {"sha": sha, "at": time.time(), "error": "Index build exceeded its time budget"}
237
  _ev("build_abandoned", spec=spec, seconds=round(time.time() - t0))
238
  continue
239
  if "error" in result:
 
321
 
322
  def run(store, *, rows: list[dict[str, Any]] | None = None, budget_s: float = 1800, max_builds: int = 100, keep: int = 5,
323
  min_ratio: float = 0.8, force: bool = False, index: bool = True, dry_run: bool = False,
324
+ out: Path | None = None, listing_at: float | None = None) -> dict[str, Any]:
325
  """One indexer run (see the module docstring). `rows` stands in for the Hub listing (tests). Raises IndexerError
326
  (or anything else) without publishing."""
327
  from . import catalog, config, snapshot
 
333
  _ev("pointer_unreadable", error=str(e))
334
  prev = None
335
  rows = rows if rows is not None else fetch_listing()
336
+ listing_at = listing_at if listing_at is not None else time.time()
337
  rows = [r for r in rows if not r.get("private") and not r.get("gated")]
338
  if not force:
339
  guard(rows, prev, min_ratio)
340
  # catalog's own readers (environments, featured lookups) see this listing, and the app's fallback path finds it
341
  with catalog._listing_lock:
342
+ catalog._env_list.update(rows=rows, at=listing_at, full=True)
343
  if not dry_run:
344
  import gzip
345
 
346
  catalog.atomic_write(config.STORAGE_DIR / "listing.json.gz",
347
+ gzip.compress(json.dumps({"at": listing_at, "full": True, "rows": rows}, default=str).encode()))
348
  report: dict[str, Any] = {"listing": len(rows)}
349
  state = load_state()
350
  if index and not dry_run:
app/main.py CHANGED
@@ -23,6 +23,7 @@ from pydantic import BaseModel, Field
23
 
24
  from . import auth, catalog, config, endpoints, envs, http, mcp_bridge, models, runner, runtime, search_api, seo, settings, space_files, spaces_live, store, version
25
  from .envs import registry
 
26
 
27
  app = FastAPI(title="HF RL Explorer", docs_url="/api/docs")
28
  app.include_router(auth.router)
@@ -50,6 +51,10 @@ def _shutdown() -> None:
50
  """Live rollouts' events to the store, and locally the capture proxy's tunnels closed: gradio starts `frpc` as a child
51
  that outlives this process otherwise, and stale tunnels pile up across restarts."""
52
  import sys
 
 
 
 
53
 
54
  store.flush_due(force=True)
55
  tunneling = sys.modules.get("gradio.tunneling")
@@ -63,9 +68,12 @@ def _shutdown() -> None:
63
  @app.on_event("startup")
64
  def _startup() -> None:
65
  import threading
 
66
 
67
  config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
68
  config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
 
 
69
  # rollouts whose worker is gone are marked interrupted by the watcher below, once they've been silent a while
70
  threading.Thread(target=runner.watch_cancel_requests, daemon=True, name="cancel-requests").start()
71
  if os.environ.get("RLX_WARM", "1") == "1":
@@ -157,8 +165,15 @@ def environments():
157
 
158
  @app.get("/api/spaces/{org}/{name}")
159
  def space_page(org: str, name: str):
160
- """An environment Space: its card and README, and what its OpenEnv server says about itself."""
161
- return _hub_errors(catalog.space, f"{org}/{name}")
 
 
 
 
 
 
 
162
 
163
 
164
  # ── live Spaces: status, wake/restart, Task API, playground ─────────────────
@@ -181,7 +196,7 @@ def space_live(org: str, name: str, fresh: bool = False):
181
  with no MCP tools of its own (a reset/step environment) still has tools for an agent: `bridge_tools` are exactly what
182
  this explorer's MCP bridge offers for it (reset, step with its typed action, state, its Task API), for the page."""
183
  info = _live(spaces_live.probe, f"{org}/{name}", fresh)
184
- if info.get("running") and not info.get("mcp") and (info.get("step_api") or info.get("api") or info.get("task_api")):
185
  info = {**info, "bridge_tools": mcp_bridge._tools(info)}
186
  return info
187
 
@@ -204,14 +219,14 @@ def space_restart(org: str, name: str, request: Request):
204
 
205
 
206
  @app.get("/api/spaces/{org}/{name}/tasks")
207
- def space_tasks(org: str, name: str, split: str = "", start: int = 0, stop: int = 20):
208
  """A page of tasks from the server's Task API (answer-like fields left out)."""
209
- return _live(spaces_live.tasks, f"{org}/{name}", split, start, stop)
210
 
211
 
212
  @app.get("/api/spaces/{org}/{name}/task")
213
- def space_task(org: str, name: str, split: str = "", index: int = 0):
214
- return _live(spaces_live.task, f"{org}/{name}", split, index)
215
 
216
 
217
  class PlayIn(BaseModel):
@@ -335,9 +350,10 @@ def env_data(org: str, name: str, request: Request, ref: str, part: str):
335
  def env_raw(org: str, name: str, request: Request, ref: str, f: str, download: bool = False):
336
  """One of a task's files as it is (an image, a page for a sandboxed frame). Never runs with this site's origin."""
337
  data, media = _env(registry.raw, f"{org}/{name}", ref, f, _token(request))
338
- fname = f.rsplit("/", 1)[-1].replace('"', "")
 
339
  return Response(data, media_type=media, headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff",
340
- "Content-Disposition": f'{"attachment" if download else "inline"}; filename="{fname}"'})
341
 
342
 
343
  @app.get("/api/env/{org}/{name}/random")
@@ -484,19 +500,19 @@ PUBLIC_FIELDS = ("id", "dataset", "path", "task_id", "title", "collection", "dif
484
 
485
  def _shareable(r: dict) -> bool:
486
  """Shown to everyone: finished and graded, on a public dataset, and not taken off Community by an admin."""
487
- return (r.get("status") == "done" and r.get("reward") is not None and not r.get("restricted")
488
- and r["id"] not in set(settings.get("hidden_runs", [])))
489
 
490
 
491
  def _public_view(r: dict) -> dict:
492
  v = {k: r.get(k) for k in PUBLIC_FIELDS}
 
493
  if r.get("endpoint"):
494
  v["endpoint"] = {"custom": True}
495
  return {**v, "visibility": "public"}
496
 
497
 
498
  def _owner_view(r: dict) -> dict:
499
- return {**r, "is_owner": True}
500
 
501
 
502
  def _visible(request: Request, run_id: str) -> tuple[dict, bool]:
@@ -530,7 +546,7 @@ def my_runs(request: Request, d: str = "", p: str | None = None):
530
  @app.get("/api/runs/{run_id}")
531
  def run_detail(run_id: str, request: Request, after: int = 0):
532
  r, owner = _visible(request, run_id)
533
- if r.get("runner") == "mimo": # the MiMo harness: its events, live while it runs (app/mimo/runner/core.py)
534
  from .mimo.runner import core as mimo
535
 
536
  live = mimo.live(run_id)
@@ -570,85 +586,17 @@ def set_visibility(run_id: str, body: VisibilityIn, request: Request):
570
  return {"run": _owner_view(store.update(run_id, visibility=body.visibility))}
571
 
572
 
573
- DOMAINS = {"code": "Code", "webdev": "Webdev", "cyber": "Cyber", "music": "Music", "general": "General"}
574
-
575
-
576
- def _group(r: dict) -> str:
577
- """Where a rollout's task belongs, for the leaderboard's columns: a MiMo domain, else its environment."""
578
- if r.get("domain") in DOMAINS:
579
- return DOMAINS[r["domain"]]
580
- return (r.get("dataset") or "?").split("/")[-1]
581
-
582
-
583
- def _served(r: dict) -> str:
584
- return "own endpoint" if r.get("endpoint") else (r.get("provider") or "auto")
585
-
586
-
587
- def _band(r: dict) -> str:
588
- rw = r.get("reward")
589
- if rw is None or r.get("status") != "done":
590
- return "unscored"
591
- return "full" if rw >= 0.999 else "zero" if rw <= 0 else "partial"
592
-
593
-
594
- def _stats(runs: list[dict]) -> dict:
595
- """How the rollouts shown did: the spread of rewards, each model's mean, and each model in each group."""
596
- scored = [r for r in runs if r.get("reward") is not None and r.get("status") == "done"]
597
- hist = [0] * 10
598
- for r in scored:
599
- hist[min(9, max(0, int(float(r["reward"]) * 10)))] += 1
600
- by: dict[str, list[float]] = {}
601
- for r in scored:
602
- by.setdefault(r.get("model") or "custom endpoint", []).append(float(r["reward"]))
603
- models_ = sorted(({"model": m, "custom": any(x.get("endpoint") for x in runs if (x.get("model") or "custom endpoint") == m), "runs": len(v),
604
- "mean": round(sum(v) / len(v), 4), "best": max(v), "full": sum(x >= 0.999 for x in v),
605
- "solved": sum(x > 0 for x in v)} for m, v in by.items()), key=lambda x: (-x["mean"], -x["runs"]))
606
- cells: dict[str, dict[str, list[float]]] = {}
607
- for r in scored:
608
- cells.setdefault(r.get("model") or "custom endpoint", {}).setdefault(_group(r), []).append(float(r["reward"]))
609
- matrix = {m: {g: {"mean": round(sum(v) / len(v), 4), "runs": len(v)} for g, v in gs.items()} for m, gs in cells.items()}
610
- return {"runs": len(runs), "scored": len(scored), "mean": round(sum(float(r["reward"]) for r in scored) / len(scored), 4) if scored else None,
611
- "full": sum(float(r["reward"]) >= 0.999 for r in scored), "histogram": hist, "by_model": models_, "models": models_, "matrix": matrix,
612
- "datasets": len({r.get("dataset") for r in runs}), "tasks": len({(r.get("dataset"), r.get("path")) for r in runs})}
613
-
614
-
615
- def _counts(runs: list[dict], f) -> list[list]:
616
- c: dict[str, int] = {}
617
- for r in runs:
618
- v = f(r)
619
- if v:
620
- c[v] = c.get(v, 0) + 1
621
- return [[k, n] for k, n in sorted(c.items(), key=lambda kv: -kv[1])]
622
-
623
-
624
  @app.get("/api/community")
625
  def community(dataset: str = "", group: str = "", model: str = "", served: str = "", judge: str = "", reward: str = "", thinking: str = "",
626
  runner: str = "", q: str = "", sort: str = "new", d: str = "", p: str | None = None, limit: int = 100, offset: int = 0):
627
- """Public, graded rollouts, by anyone, without who ran them: filtered every way a reader might want, and how each
628
- model does, overall and in each environment (MiMo's domains count as environments of their own)."""
629
  everyone = [r for r in store.list_runs(public=True, limit=100000) if _shareable(r)]
630
- dataset = dataset or d
631
-
632
- def keep(r):
633
- return ((not dataset or r.get("dataset") == dataset) and (p is None or r.get("path") == p) and (not group or _group(r) == group)
634
- and (not model or (r.get("model") or "") == model) and (not served or _served(r) == served)
635
- and (not judge or (r.get("judge") or "") == judge) and (not reward or _band(r) == reward)
636
- and (not thinking or ((r.get("params") or {}).get("thinking") or "default") == thinking)
637
- and (not runner or (r.get("runner") or "harbor") == runner)
638
- and (not q or q.lower() in f"{r.get('title', '')} {r.get('task_id', '')} {r.get('path', '')}".lower()))
639
- runs = [r for r in everyone if keep(r)]
640
- key = {"reward_desc": lambda r: -(r.get("reward") if r.get("reward") is not None else -1),
641
- "reward_asc": lambda r: (r.get("reward") if r.get("reward") is not None else 2),
642
- "cost_asc": lambda r: (r.get("cost") or {}).get("total", 0), "cost_desc": lambda r: -(r.get("cost") or {}).get("total", 0)}.get(sort)
643
- if key:
644
- runs.sort(key=key)
645
- facets = {"group": _counts(everyone, _group), "dataset": _counts(everyone, lambda r: r.get("dataset")),
646
- "model": _counts(everyone, lambda r: r.get("model")), "served": _counts(everyone, _served), "judge": _counts(everyone, lambda r: r.get("judge")),
647
- "reward": _counts(everyone, _band), "thinking": _counts(everyone, lambda r: (r.get("params") or {}).get("thinking") or "default"),
648
- "runner": _counts(everyone, lambda r: r.get("runner") or "harbor")}
649
- page = runs[max(0, offset): max(0, offset) + max(1, min(limit, 500))]
650
- return {"stats": _stats(runs), "facets": facets, "total": len(runs), "tasks": len({(r.get("dataset"), r.get("path")) for r in runs}),
651
- "runs": [{**_public_view(r), "group": _group(r), "shot": r.get("domain") == "webdev" and store.has_artifact(r["id"], "screenshot.jpg")} for r in page]}
652
 
653
 
654
  @app.get("/api/community/tasks")
@@ -664,7 +612,7 @@ def community_tasks(dataset: str):
664
  if r.get("reward") is not None:
665
  t["scored"] += 1
666
  t["sum"] += float(r["reward"])
667
- t["best"] = max(t["best"] if t["best"] is not None else 0, float(r["reward"]))
668
  return {"tasks": {k: {"runs": v["runs"], "mean": round(v["sum"] / v["scored"], 4) if v["scored"] else None, "best": v["best"], "last": v["last"],
669
  "title": v["title"]} for k, v in out.items()}}
670
 
@@ -692,6 +640,14 @@ def run_artifact(run_id: str, name: str, request: Request):
692
  LLM_REPLY_MAX = 64 * 1024 * 1024
693
 
694
 
 
 
 
 
 
 
 
 
695
  @app.post("/api/llm/{cap}/v1/chat/completions")
696
  async def llm_proxy(cap: str, request: Request):
697
  import asyncio
@@ -712,6 +668,8 @@ async def llm_proxy(cap: str, request: Request):
712
  body = json.loads(raw)
713
  except ValueError:
714
  return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
 
 
715
  up = r.upstream(str(body.get("model") or ""))
716
  if up is None:
717
  return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
 
23
 
24
  from . import auth, catalog, config, endpoints, envs, http, mcp_bridge, models, runner, runtime, search_api, seo, settings, space_files, spaces_live, store, version
25
  from .envs import registry
26
+ from . import community as community_data
27
 
28
  app = FastAPI(title="HF RL Explorer", docs_url="/api/docs")
29
  app.include_router(auth.router)
 
51
  """Live rollouts' events to the store, and locally the capture proxy's tunnels closed: gradio starts `frpc` as a child
52
  that outlives this process otherwise, and stale tunnels pile up across restarts."""
53
  import sys
54
+ from . import auto_index, space_checks
55
+
56
+ space_checks.stop()
57
+ auto_index.stop()
58
 
59
  store.flush_due(force=True)
60
  tunneling = sys.modules.get("gradio.tunneling")
 
68
  @app.on_event("startup")
69
  def _startup() -> None:
70
  import threading
71
+ from . import auto_index, space_checks
72
 
73
  config.STORAGE_DIR.mkdir(parents=True, exist_ok=True)
74
  config.INDEX_DIR.mkdir(parents=True, exist_ok=True)
75
+ space_checks.start()
76
+ auto_index.start()
77
  # rollouts whose worker is gone are marked interrupted by the watcher below, once they've been silent a while
78
  threading.Thread(target=runner.watch_cancel_requests, daemon=True, name="cancel-requests").start()
79
  if os.environ.get("RLX_WARM", "1") == "1":
 
165
 
166
  @app.get("/api/spaces/{org}/{name}")
167
  def space_page(org: str, name: str):
168
+ """Repository metadata is distinct from verified live OpenEnv support."""
169
+ from . import space_checks
170
+ source = _hub_errors(catalog.space, f"{org}/{name}")
171
+ check = space_checks.inventory().get(f"{org}/{name}", {})
172
+ verified = space_checks.verified(check)
173
+ return {**source, "declared_openenv": bool(source.get("openenv")), "openenv": verified,
174
+ "framework": "openenv" if verified else "ors" if source.get("framework") == "ors" else "space",
175
+ "badges": [b for b in source.get("badges", []) if verified or b != "OpenEnv"],
176
+ "api_status": space_checks.status(check)}
177
 
178
 
179
  # ── live Spaces: status, wake/restart, Task API, playground ─────────────────
 
196
  with no MCP tools of its own (a reset/step environment) still has tools for an agent: `bridge_tools` are exactly what
197
  this explorer's MCP bridge offers for it (reset, step with its typed action, state, its Task API), for the page."""
198
  info = _live(spaces_live.probe, f"{org}/{name}", fresh)
199
+ if info.get("running") and (info.get("mcp") or info.get("step_api") or info.get("api") or info.get("task_api")):
200
  info = {**info, "bridge_tools": mcp_bridge._tools(info)}
201
  return info
202
 
 
219
 
220
 
221
  @app.get("/api/spaces/{org}/{name}/tasks")
222
+ def space_tasks(org: str, name: str, split: str = "", start: int = 0, stop: int = 20, env: str = ""):
223
  """A page of tasks from the server's Task API (answer-like fields left out)."""
224
+ return _live(spaces_live.tasks, f"{org}/{name}", split, start, stop, env)
225
 
226
 
227
  @app.get("/api/spaces/{org}/{name}/task")
228
+ def space_task(org: str, name: str, split: str = "", index: int = 0, env: str = ""):
229
+ return _live(spaces_live.task, f"{org}/{name}", split, index, env)
230
 
231
 
232
  class PlayIn(BaseModel):
 
350
  def env_raw(org: str, name: str, request: Request, ref: str, f: str, download: bool = False):
351
  """One of a task's files as it is (an image, a page for a sandboxed frame). Never runs with this site's origin."""
352
  data, media = _env(registry.raw, f"{org}/{name}", ref, f, _token(request))
353
+ # RFC 5987 handles Unicode and keeps control characters out of HTTP headers.
354
+ fname = quote(f.rsplit("/", 1)[-1], safe="")
355
  return Response(data, media_type=media, headers={"Content-Security-Policy": "sandbox", "X-Content-Type-Options": "nosniff",
356
+ "Content-Disposition": f"{'attachment' if download else 'inline'}; filename*=UTF-8''{fname}"})
357
 
358
 
359
  @app.get("/api/env/{org}/{name}/random")
 
500
 
501
  def _shareable(r: dict) -> bool:
502
  """Shown to everyone: finished and graded, on a public dataset, and not taken off Community by an admin."""
503
+ return community_data.is_public(r, set(settings.get("hidden_runs", [])))
 
504
 
505
 
506
  def _public_view(r: dict) -> dict:
507
  v = {k: r.get(k) for k in PUBLIC_FIELDS}
508
+ v["runner"] = community_data.runner(r)
509
  if r.get("endpoint"):
510
  v["endpoint"] = {"custom": True}
511
  return {**v, "visibility": "public"}
512
 
513
 
514
  def _owner_view(r: dict) -> dict:
515
+ return {**r, "runner": community_data.runner(r), "is_owner": True}
516
 
517
 
518
  def _visible(request: Request, run_id: str) -> tuple[dict, bool]:
 
546
  @app.get("/api/runs/{run_id}")
547
  def run_detail(run_id: str, request: Request, after: int = 0):
548
  r, owner = _visible(request, run_id)
549
+ if community_data.runner(r) in ("mimo", "nemo-gym"): # event-based hosted runners
550
  from .mimo.runner import core as mimo
551
 
552
  live = mimo.live(run_id)
 
586
  return {"run": _owner_view(store.update(run_id, visibility=body.visibility))}
587
 
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
  @app.get("/api/community")
590
  def community(dataset: str = "", group: str = "", model: str = "", served: str = "", judge: str = "", reward: str = "", thinking: str = "",
591
  runner: str = "", q: str = "", sort: str = "new", d: str = "", p: str | None = None, limit: int = 100, offset: int = 0):
592
+ """Finished, graded public rollouts from any saved runner, without account identities or private endpoint locations."""
 
593
  everyone = [r for r in store.list_runs(public=True, limit=100000) if _shareable(r)]
594
+ filters = {"dataset": dataset or d, "path": p, "group": group, "model": model, "served": served, "judge": judge,
595
+ "reward": reward, "thinking": thinking, "runner": runner, "q": q[:200]}
596
+ out = community_data.query(everyone, filters, sort=sort, offset=offset, limit=limit)
597
+ out["runs"] = [{**_public_view(r), "runner": community_data.runner(r), "group": community_data.group(r),
598
+ "shot": r.get("domain") == "webdev" and store.has_artifact(r["id"], "screenshot.jpg")} for r in out["runs"]]
599
+ return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
600
 
601
 
602
  @app.get("/api/community/tasks")
 
612
  if r.get("reward") is not None:
613
  t["scored"] += 1
614
  t["sum"] += float(r["reward"])
615
+ t["best"] = max(t["best"], float(r["reward"])) if t["best"] is not None else float(r["reward"])
616
  return {"tasks": {k: {"runs": v["runs"], "mean": round(v["sum"] / v["scored"], 4) if v["scored"] else None, "best": v["best"], "last": v["last"],
617
  "title": v["title"]} for k, v in out.items()}}
618
 
 
640
  LLM_REPLY_MAX = 64 * 1024 * 1024
641
 
642
 
643
+ @app.get("/api/llm/{cap}/health")
644
+ def llm_health(cap: str):
645
+ from .mimo.runner import core as events
646
+ if events.by_cap(cap) is None:
647
+ raise HTTPException(404, "unknown or finished rollout")
648
+ return {"status": "ok"}
649
+
650
+
651
  @app.post("/api/llm/{cap}/v1/chat/completions")
652
  async def llm_proxy(cap: str, request: Request):
653
  import asyncio
 
668
  body = json.loads(raw)
669
  except ValueError:
670
  return JSONResponse({"error": {"message": "invalid JSON"}}, 400)
671
+ if not isinstance(body, dict):
672
+ return JSONResponse({"error": {"message": "JSON object required"}}, 400)
673
  up = r.upstream(str(body.get("model") or ""))
674
  if up is None:
675
  return JSONResponse({"error": {"message": "this rollout may not call that model"}}, 403)
app/mcp_bridge.py CHANGED
@@ -51,6 +51,9 @@ def _limited(owner: str) -> bool:
51
  now = time.time()
52
  with _lock:
53
  hits = [t for t in _hits.get(owner, []) if now - t < RATE[1]]
 
 
 
54
  hits.append(now)
55
  _hits[owner] = hits
56
  if len(_hits) > 5000:
@@ -84,19 +87,31 @@ def _tools(info: dict) -> list[dict[str, Any]]:
84
  ]
85
  # a tool server takes its actions as tool calls: it gets reset and state, not step
86
  tools += [t for t in episode if not (names and t["name"] == "step")]
87
- # a server's own routes (NeMo Gym, ORS, custom): one tool each, named after its path, when it has no tools of its own
88
- if not info.get("mcp") and info.get("api"):
89
  taken = {t["name"] for t in tools}
90
  for r in info["api"]:
91
  name = _route_tool(r)
92
  if name in taken:
93
  continue
94
  taken.add(name)
95
- props = {p: {"type": "string", "description": "in the path"} for p in r["params"]}
96
- body = r.get("schema") or {}
97
- props.update({k: v for k, v in (body.get("properties") or {}).items()})
 
 
 
 
 
 
 
 
 
 
 
 
98
  tools.append({"name": name, "description": f"{r['method']} {r['path']}" + (f": {r['summary']}" if r["summary"] else ""),
99
- "inputSchema": {"type": "object", "properties": props, "required": list(r["params"]) + list(body.get("required") or [])}})
100
  if info.get("task_api"):
101
  extra = [
102
  {"name": "list_splits", "description": "The server's task splits and how many tasks each holds.", "inputSchema": {"type": "object", "properties": {}}},
@@ -163,11 +178,12 @@ def _call(spec: str, info: dict, client: dict, name: str, args: dict) -> dict[st
163
  if name == "list_tasks":
164
  return _content(live.tasks(spec, str(args.get("split", "")), int(args.get("start", 0) or 0), int(args.get("stop", 20) or 20)))
165
  return _content(live.task(spec, str(args.get("split", "")), int(args.get("index", 0) or 0)))
166
- route = next((r for r in info.get("api") or [] if _route_tool(r) == name), None) if not own else None
167
  if route is not None:
168
- params = {p: args.get(p) for p in route["params"]}
169
- body = {k: v for k, v in args.items() if k not in route["params"]} if route["method"] == "POST" else None
170
- out = live.act(_play(spec, client), client["owner"], "http", {"method": route["method"], "path": route["path"], "params": params, "body": body})["result"]
 
171
  payload = out.get("json", out.get("text"))
172
  result = _content(payload if payload is not None else {"status": out["status"]})
173
  if out["status"] >= 400:
 
51
  now = time.time()
52
  with _lock:
53
  hits = [t for t in _hits.get(owner, []) if now - t < RATE[1]]
54
+ if len(hits) >= RATE[0]:
55
+ _hits[owner] = hits
56
+ return True
57
  hits.append(now)
58
  _hits[owner] = hits
59
  if len(_hits) > 5000:
 
87
  ]
88
  # a tool server takes its actions as tool calls: it gets reset and state, not step
89
  tools += [t for t in episode if not (names and t["name"] == "step")]
90
+ # Keep published grading/seed routes alongside native tools, without naming collisions.
91
+ if info.get("api"):
92
  taken = {t["name"] for t in tools}
93
  for r in info["api"]:
94
  name = _route_tool(r)
95
  if name in taken:
96
  continue
97
  taken.add(name)
98
+ # Separate request locations: a body key named 'id' or 'query' must
99
+ # not overwrite a path/query parameter with the same name.
100
+ props, required = {}, []
101
+ if r["params"]:
102
+ props["path_params"] = {"type": "object", "properties": {p: {"type": "string"} for p in r["params"]},
103
+ "required": r["params"], "additionalProperties": False}
104
+ required.append("path_params")
105
+ if (r.get("query_schema") or {}).get("properties"):
106
+ props["query"] = r["query_schema"]
107
+ if r["query_schema"].get("required"):
108
+ required.append("query")
109
+ if r["method"] == "POST":
110
+ props["body"] = r.get("schema") or {"type": "object"}
111
+ if r.get("body_required"):
112
+ required.append("body")
113
  tools.append({"name": name, "description": f"{r['method']} {r['path']}" + (f": {r['summary']}" if r["summary"] else ""),
114
+ "inputSchema": {"type": "object", "properties": props, "required": required, "additionalProperties": False}})
115
  if info.get("task_api"):
116
  extra = [
117
  {"name": "list_splits", "description": "The server's task splits and how many tasks each holds.", "inputSchema": {"type": "object", "properties": {}}},
 
178
  if name == "list_tasks":
179
  return _content(live.tasks(spec, str(args.get("split", "")), int(args.get("start", 0) or 0), int(args.get("stop", 20) or 20)))
180
  return _content(live.task(spec, str(args.get("split", "")), int(args.get("index", 0) or 0)))
181
+ route = next((r for r in info.get("api") or [] if _route_tool(r) == name), None)
182
  if route is not None:
183
+ params = args.get("path_params") if isinstance(args.get("path_params"), dict) else {}
184
+ query = args.get("query") if isinstance(args.get("query"), dict) else {}
185
+ body = args.get("body") if route["method"] == "POST" else None
186
+ out = live.act(_play(spec, client), client["owner"], "http", {"method": route["method"], "path": route["path"], "params": params, "query": query, "body": body})["result"]
187
  payload = out.get("json", out.get("text"))
188
  result = _content(payload if payload is not None else {"status": out["status"]})
189
  if out["status"] >= 400:
app/mimo/__init__.py CHANGED
@@ -3,7 +3,7 @@ every one of its 7,780 tasks browsable in full (brief, agent prompt, systems and
3
  repository, the grader with its judge prompts), and rollouts on its own harness: OpenCode on an HF Sandbox, graded by
4
  Xiaomi's graders (hidden tests, a fuzz crash, rubric checks with a judge model, a screenshot judge, the music scorer).
5
 
6
- The code is the MiMo explorer's (mimo-explorer/app), kept close to it so the two can be compared and synced:
7
  catalog.py the task views and what the runner needs (data/: the browsing index, built by build_data.py)
8
  judges.py the judge prompts, pinned
9
  previews.py workspace file previews (spreadsheets, documents, slides, PDFs)
 
3
  repository, the grader with its judge prompts), and rollouts on its own harness: OpenCode on an HF Sandbox, graded by
4
  Xiaomi's graders (hidden tests, a fuzz crash, rubric checks with a judge model, a screenshot judge, the music scorer).
5
 
6
+ The code is the MiMo explorer's (tooling/mimo-rl-explorer/app), kept close to it so the two can be compared and synced:
7
  catalog.py the task views and what the runner needs (data/: the browsing index, built by build_data.py)
8
  judges.py the judge prompts, pinned
9
  previews.py workspace file previews (spreadsheets, documents, slides, PDFs)
app/mimo/runner/core.py CHANGED
@@ -137,7 +137,7 @@ class Rollout:
137
  self.update(flavor=flavor)
138
  t = time.time()
139
  self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
140
- forward_hf_token=not config.PUBLIC_URL, token=self.token, start_timeout=900,
141
  labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
142
  self.update(sandbox_id=self.sandbox.id)
143
  self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
@@ -152,8 +152,30 @@ class Rollout:
152
  return config.ROUTER, self.token, mid
153
 
154
  def llm_base(self) -> str | None:
155
- """The OpenAI-compatible base URL the sandbox should use, or None to call the provider directly."""
156
- return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1" if config.PUBLIC_URL else None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
  def upstream(self, model: str) -> tuple[str, str | None] | None:
159
  """For the model proxy: (base URL, key) for a model this rollout may call, else None."""
@@ -193,12 +215,16 @@ class Rollout:
193
  def execute(self) -> None:
194
  from .domains import ADAPTERS
195
 
196
- adapter = ADAPTERS[self.run["domain"]]
 
 
 
197
  try:
198
  self.update(status="starting", started_at=time.time())
199
  self.settle_model()
200
  result = adapter.run(self)
201
- self.update(status="done", reward=result.get("reward"), reward_error=result.get("error"),
 
202
  finished_at=time.time(), cost=self.cost_now())
203
  self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
204
  else f"not scored: {result.get('error')}")
@@ -217,10 +243,12 @@ class Rollout:
217
  self._flush()
218
  self.stop_sandbox()
219
  self.agent_key = None
 
220
  with _live_lock:
221
  _caps.pop(self.cap, None)
222
  with _live_lock:
223
  _live.pop(self.id, None)
 
224
 
225
  def stop_sandbox(self) -> None:
226
  if self.sandbox is None:
@@ -270,11 +298,12 @@ def submit(user: str, token: str, task: dict, model: str, provider: str | None,
270
  "facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
271
  "status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
272
  "visibility": "private" if visibility == "private" else "public",
273
- "image": catalog.image_for(task["id"]) if task["domain"] != "music" else None,
274
  "provenance": version.provenance(),
275
  **(extra or {}),
276
  })
277
  r = Rollout(run, token, agent_key)
 
278
  with _live_lock:
279
  _live[r.id] = r
280
  _caps[r.cap] = r
 
137
  self.update(flavor=flavor)
138
  t = time.time()
139
  self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
140
+ forward_hf_token=False, token=self.token, start_timeout=900,
141
  labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
142
  self.update(sandbox_id=self.sandbox.id)
143
  self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
 
152
  return config.ROUTER, self.token, mid
153
 
154
  def llm_base(self) -> str | None:
155
+ """Only a per-run capability reaches the sandbox, including local previews."""
156
+ if config.PUBLIC_URL:
157
+ return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1"
158
+ from ...runner import _proxy_url
159
+ return f"{_proxy_url().rstrip('/')}/rlx/llm/{self.cap}/v1"
160
+
161
+ def check_model_network(self):
162
+ """Check the exact relay path from the sandbox, without a model call."""
163
+ url = self.llm_base().removesuffix("/v1") + "/health"
164
+ script = ("import urllib.request,urllib.error\n"
165
+ f"url={url!r}\n"
166
+ "try:\n"
167
+ " r=urllib.request.urlopen(url,timeout=15)\n"
168
+ " print('relay HTTP',r.status)\n"
169
+ "except urllib.error.HTTPError as e:\n"
170
+ " print('relay HTTP',e.code);raise SystemExit(1)\n"
171
+ "except Exception as e:\n"
172
+ " print(type(e).__name__);raise SystemExit(1)\n")
173
+ import shlex
174
+ result = self.sh("python3 -c " + shlex.quote(script), timeout=25)
175
+ if result.exit_code:
176
+ raise RuntimeError("The sandbox cannot reach the model relay. Check HTTPS egress from the sandbox and ingress to the explorer. "
177
+ "No account token was forwarded. " + (result.stdout or "")[-120:])
178
+ self.log("Network check passed: sandbox → model relay. HF account token stays on the explorer.")
179
 
180
  def upstream(self, model: str) -> tuple[str, str | None] | None:
181
  """For the model proxy: (base URL, key) for a model this rollout may call, else None."""
 
215
  def execute(self) -> None:
216
  from .domains import ADAPTERS
217
 
218
+ if self.run.get("runner") == "nemo-gym":
219
+ from ... import nemo_runner as adapter
220
+ else:
221
+ adapter = ADAPTERS[self.run["domain"]]
222
  try:
223
  self.update(status="starting", started_at=time.time())
224
  self.settle_model()
225
  result = adapter.run(self)
226
+ self.update(status="done", phase="done", reward=result.get("reward"), reward_error=result.get("error"),
227
+ graded=result.get("reward") is not None, wall_s=round(time.time() - self.t0, 2),
228
  finished_at=time.time(), cost=self.cost_now())
229
  self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
230
  else f"not scored: {result.get('error')}")
 
243
  self._flush()
244
  self.stop_sandbox()
245
  self.agent_key = None
246
+ self.native_task = None
247
  with _live_lock:
248
  _caps.pop(self.cap, None)
249
  with _live_lock:
250
  _live.pop(self.id, None)
251
+ self.token = ""
252
 
253
  def stop_sandbox(self) -> None:
254
  if self.sandbox is None:
 
298
  "facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
299
  "status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
300
  "visibility": "private" if visibility == "private" else "public",
301
+ "image": catalog.image_for(task["id"]) if task["domain"] not in ("music", "nemo-gym") else None,
302
  "provenance": version.provenance(),
303
  **(extra or {}),
304
  })
305
  r = Rollout(run, token, agent_key)
306
+ r.native_task = task.get("native_task") # reference action stays in memory; never in the run record
307
  with _live_lock:
308
  _live[r.id] = r
309
  _caps[r.cap] = r
app/mimo/runner/opencode.py CHANGED
@@ -119,6 +119,7 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
119
  steps = params.get("steps") or steps
120
  timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
121
  proxy = r.llm_base()
 
122
  cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
123
  r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
124
  r.sandbox.files.write("/tmp/prompt.txt", prompt)
@@ -166,6 +167,10 @@ def run(r, prompt: str, cwd: str, *, steps: int, timeout: float, user: str | Non
166
  r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
167
  if buf[0].strip():
168
  _event(r, buf[0], texts)
 
 
 
 
169
  if res.timed_out:
170
  r.emit("error", text=f"The agent hit the {int(timeout // 60)}-minute limit; grading what it left.")
171
  return texts[-1] if texts else ""
@@ -209,8 +214,8 @@ def _event(r, line: str, texts: list[str]) -> None:
209
  "so OpenCode ends the session there, as it does in Xiaomi's harness.")
210
  elif typ == "error":
211
  err = ev.get("error") or part
212
- r.emit("error", text=(err.get("data") or {}).get("message") or json.dumps(err)[:800]
213
- if isinstance(err, dict) else str(err)[:800])
214
 
215
 
216
  def _scripted(r, cwd: str, user: str | None) -> str:
 
119
  steps = params.get("steps") or steps
120
  timeout = params["timeout_min"] * 60 if params.get("timeout_min") else timeout
121
  proxy = r.llm_base()
122
+ r.check_model_network()
123
  cfg = config_for(r.run["model"], r.run.get("provider"), steps, mcp, ep, params, proxy)
124
  r.sandbox.files.write("/tmp/opencode.json", json.dumps(cfg))
125
  r.sandbox.files.write("/tmp/prompt.txt", prompt)
 
167
  r.sandbox.run("rm -f /root/.agent_key", shell=True, check=False, timeout=30)
168
  if buf[0].strip():
169
  _event(r, buf[0], texts)
170
+ if getattr(r, "agent_failure", None):
171
+ raise RuntimeError("The agent's model call failed; this run is not graded. " + r.agent_failure[:500])
172
+ if res.exit_code and not res.timed_out:
173
+ raise RuntimeError(f"The agent exited with code {res.exit_code}; this run is not graded.")
174
  if res.timed_out:
175
  r.emit("error", text=f"The agent hit the {int(timeout // 60)}-minute limit; grading what it left.")
176
  return texts[-1] if texts else ""
 
214
  "so OpenCode ends the session there, as it does in Xiaomi's harness.")
215
  elif typ == "error":
216
  err = ev.get("error") or part
217
+ r.agent_failure = ((err.get("data") or {}).get("message") or json.dumps(err)[:800]) if isinstance(err, dict) else str(err)[:800]
218
+ r.emit("error", text=r.agent_failure)
219
 
220
 
221
  def _scripted(r, cwd: str, user: str | None) -> str:
app/nemo_runner.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """One-step NeMo Pivot prediction, scored by NVIDIA's unmodified comparator.
2
+
3
+ Tool definitions are prediction targets; tool calls are never executed. No task
4
+ code, model key, or reference action enters a sandbox. Other Gym agents require
5
+ their resources server and are intentionally outside this adapter's scope.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+
11
+ import httpx
12
+ from pydantic import TypeAdapter
13
+
14
+ from .vendor.nemo_gym.verification_utils import (
15
+ ActionComparator, ExpectedAction, FunctionCallAction, FunctionCallBatchAction,
16
+ MessageAction, StepRewardCategory, ToolCallComparatorConfig,
17
+ )
18
+
19
+ REVISION = "3ef478df1ee163134d32a3f291f0a9e5981d0e52"
20
+ CONFIGS = {
21
+ "resources_servers/single_step_tool_use_with_argument_comparison/configs/"
22
+ + name + ".yaml" for name in (
23
+ "single_step_tool_use_with_argument_comparison",
24
+ "toolcall_schema_single_step_tool_use_with_argument_comparison",
25
+ )
26
+ }
27
+
28
+
29
+ def supported(agent):
30
+ return agent.get("known") and agent.get("agent_type") == "tool_simulation_agent" and agent.get("config") in CONFIGS
31
+
32
+
33
+ def strict_schema(schema):
34
+ if not isinstance(schema, dict):
35
+ return False
36
+ if schema.get("type") == "object" or "properties" in schema:
37
+ if schema.get("additionalProperties") is not False or set(schema.get("required") or []) != set(schema.get("properties") or {}):
38
+ return False
39
+ return all(strict_schema(v) for k, v in schema.items() if k in ("items", "additionalProperties") and isinstance(v, dict)) and all(
40
+ strict_schema(v) for v in (schema.get("properties") or {}).values()) and all(
41
+ strict_schema(v) for k in ("anyOf", "oneOf", "allOf") for v in schema.get(k, []))
42
+
43
+
44
+ def relaxed_tools(row):
45
+ return [t["name"] for t in (row.get("responses_create_params") or {}).get("tools", [])
46
+ if isinstance(t, dict) and t.get("name") and t.get("strict") is True and not strict_schema(t.get("parameters"))]
47
+
48
+
49
+ def responses_request(row):
50
+ """Keep NeMo's native Responses input, including prior reasoning and tool turns.
51
+
52
+ Only function definitions are allowed: provider-hosted MCP, web search and
53
+ code execution would change a prediction task into a side-effecting agent.
54
+ """
55
+ rcp = row.get("responses_create_params")
56
+ if not isinstance(rcp, dict) or not rcp.get("input"):
57
+ raise ValueError("This row has no NeMo Gym Responses request")
58
+ if len(json.dumps(rcp)) > 200_000:
59
+ raise ValueError("This task's model input exceeds the hosted limit")
60
+ raw = rcp["input"]
61
+ if not isinstance(raw, (str, list)) or isinstance(raw, list) and len(raw) > 500:
62
+ raise ValueError("This row needs the native NeMo Gym runtime")
63
+ tools = rcp.get("tools") or []
64
+ if not isinstance(tools, list) or len(tools) > 128 or not all(
65
+ isinstance(t, dict) and t.get("type") == "function" and isinstance(t.get("name"), str) for t in tools
66
+ ):
67
+ raise ValueError("Only function prediction tools are supported here; use the native NeMo Gym runtime")
68
+ allowed = {"input", "instructions", "tools", "tool_choice", "temperature", "top_p", "parallel_tool_calls", "reasoning", "text"}
69
+ # Model, token limits, persistence and execution mode belong to the runner.
70
+ body = {k: v for k, v in rcp.items() if k in allowed and v is not None}
71
+ relaxed = set(relaxed_tools(row))
72
+ if tools:
73
+ body["tools"] = [{**t, "strict": False} if t["name"] in relaxed else t for t in tools]
74
+ return body
75
+
76
+
77
+ def prediction(response):
78
+ """Match NeMo's response_utils.extract_action: calls take precedence over text."""
79
+ if response.get("error") or response.get("status") in ("failed", "cancelled"):
80
+ error = response.get("error") or {}
81
+ raise ValueError("Inference provider did not produce a prediction: " + str(error.get("message") or response.get("status"))[:600])
82
+ if not isinstance(response.get("output"), list):
83
+ raise ValueError("Inference provider returned an invalid Responses payload")
84
+ if response.get("status") == "incomplete" and not response["output"]:
85
+ raise ValueError("The model reached its output limit without a prediction. Increase Max output tokens.")
86
+ calls, content = [], None
87
+ for item in response.get("output") or []:
88
+ if item.get("type") == "function_call":
89
+ calls.append({"function": {"name": item["name"], "arguments": item["arguments"]}})
90
+ elif item.get("type") == "message" and item.get("role") == "assistant" and content is None:
91
+ content = next((p["text"] for p in item.get("content", []) if p.get("type") == "output_text"), None)
92
+ return {"tool_calls": calls, "content": content}
93
+
94
+
95
+ def validate(row, agent):
96
+ if not supported(agent):
97
+ raise ValueError("This NeMo resources server is not hosted here")
98
+ responses_request(row)
99
+ if len(json.dumps(row.get("expected_action"))) > 100_000:
100
+ raise ValueError("The reference action exceeds the hosted verifier limit")
101
+ try:
102
+ return TypeAdapter(ExpectedAction).validate_python(row.get("expected_action"))
103
+ except ValueError:
104
+ raise ValueError("The reference action is invalid for this NeMo Gym verifier") from None
105
+
106
+
107
+ def score(expected, message):
108
+ calls = message.get("tool_calls") or []
109
+ if len(calls) > 64:
110
+ raise ValueError("The provider returned too many tool calls")
111
+ actions = [FunctionCallAction(type="function_call", name=c["function"]["name"], arguments=c["function"]["arguments"]) for c in calls]
112
+ actual = (actions[0] if len(actions) == 1 else FunctionCallBatchAction(type="function_call_batch", calls=actions)) if actions else (
113
+ MessageAction(type="message", content=message["content"]) if isinstance(message.get("content"), str) else None)
114
+ if actual is None:
115
+ return 0.0, str(StepRewardCategory.NO_ACTION_FOUND)
116
+ result = ActionComparator(config=ToolCallComparatorConfig(word_count_similarity_threshold=0.1)).compare_action(expected, actual)
117
+ return result.reward, str(result.category)
118
+
119
+
120
+ def run(r):
121
+ payload = r.native_task
122
+ expected = validate(payload["row"], payload["agent"])
123
+ body = responses_request(payload["row"])
124
+ base, key, model = r.agent_api()
125
+ params = r.run["params"]
126
+ body.update(model=model, max_output_tokens=params.get("max_tokens", 2048), stream=False, store=False)
127
+ if params.get("temperature") is not None:
128
+ body["temperature"] = params["temperature"]
129
+ r.check_cancel()
130
+ r.phase("agent", detail="One prediction; tools are not executed")
131
+ if relaxed_tools(payload["row"]):
132
+ r.log("Some source tools declare strict=true with incompatible parameter schemas. Kept the original parameters and disabled strict enforcement for: "
133
+ + ", ".join(relaxed_tools(payload["row"])))
134
+ r.emit("prompt", harness="nemo-gym", text=json.dumps({k: v for k, v in body.items() if k != "model"}, ensure_ascii=False, indent=2))
135
+ response = httpx.post(base + "/responses", json=body, headers={"Authorization": f"Bearer {key}"}, timeout=90, follow_redirects=False)
136
+ if response.status_code != 200:
137
+ raise RuntimeError(f"Inference provider returned HTTP {response.status_code}; check model availability and Inference Providers permission")
138
+ r.check_cancel()
139
+ if len(response.content) > 2_000_000:
140
+ raise ValueError("The model response exceeds the hosted limit")
141
+ result = response.json()
142
+ message = prediction(result)
143
+ if message.get("content"):
144
+ r.emit("text", text=message["content"])
145
+ for call in (message.get("tool_calls") or [])[:64]:
146
+ r.emit("tool", tool=call["function"]["name"], title="Predicted tool call", input=call["function"]["arguments"],
147
+ output="Prediction only; no tool was executed.", status="done")
148
+ usage = result.get("usage") or {}
149
+ r.add_tokens({"input": usage.get("input_tokens", 0), "output": usage.get("output_tokens", 0)})
150
+ r.emit("step", tokens=r.tokens, cost=r.model_cost)
151
+ r.update(n_turns=1)
152
+ r.phase("agent", "done")
153
+ r.phase("verify", detail="NeMo Gym's pinned argument comparator")
154
+ reward, category = score(expected, message)
155
+ r.emit("checks", checks=[{"id": "native-comparator", "passed": reward == 1, "score": reward,
156
+ "method": "NeMo Gym", "message": category}], reward=reward, summary=category)
157
+ r.phase("verify", "done", category)
158
+ return {"reward": reward}
app/runner.py CHANGED
@@ -20,6 +20,7 @@ import asyncio
20
  import contextlib
21
  import contextvars
22
  import json
 
23
  import logging
24
  import re
25
  import secrets
@@ -28,6 +29,7 @@ import threading
28
  import time
29
  from pathlib import Path
30
  from typing import Any
 
31
 
32
  from . import catalog, config, dockerfile, judge, store
33
 
@@ -243,6 +245,16 @@ async def _replay(env: Any, plan: Any) -> None:
243
 
244
 
245
  # ── the capture proxy ────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
246
  def service() -> Any:
247
  """OpenEnv's Harbor service: the capture proxy, and the URL the sandbox reaches it at."""
248
  global _service
@@ -253,6 +265,12 @@ def service() -> Any:
253
  # No default engine: every rollout brings its own (the visitor's token or endpoint).
254
  _service = HarborService(llm_url=config.ROUTER, model="", datasets=[], provider="hf", capture_level="text",
255
  expose=config.CAPTURE_EXPOSE)
 
 
 
 
 
 
256
  _patch_hf_sandbox()
257
  _patch_harbor_env()
258
  _patch_opencode_install()
@@ -525,10 +543,13 @@ def submit(user: dict[str, Any], dataset: str, path: str, harness: str, model: s
525
  """Start a rollout of task `path` (the environment's ref) of `dataset`, with agent `harness`, by `runner`; `fields`
526
  are the run option's own inputs (contract.run_option fields), checked against it."""
527
  from . import settings
 
528
 
529
  token = user.get("token")
530
  if not token:
531
  raise PermissionError("sign in to run a rollout")
 
 
532
  if not settings.get("rollouts_enabled", True):
533
  raise RuntimeError("Rollouts are paused for maintenance. Try again later.")
534
  if harness not in settings.get("agents", list(AGENT_IDS)):
@@ -547,6 +568,18 @@ def submit(user: dict[str, Any], dataset: str, path: str, harness: str, model: s
547
  if endpoint and not opt.get("endpoint", True):
548
  raise ValueError(f"the {opt['label']} runner can't use your own endpoint")
549
  fields = _check_fields(opt.get("fields") or [], fields or {})
 
 
 
 
 
 
 
 
 
 
 
 
550
  if runner == "mimo":
551
  return _submit_mimo(user, env, row, model, provider, endpoint, endpoint_key, fields, visibility, max_user, max_all)
552
  params = {**params, **{k: fields[k] for k in ("steps", "timeout_min") if k in fields}} # the Harbor runner's limits
@@ -623,8 +656,10 @@ def _check_fields(spec: list[dict[str, Any]], given: dict[str, Any]) -> dict[str
623
 
624
  if not isinstance(v, str) or v not in {m["id"] for m in models.catalog().get(f.get("pool") or "", [])}:
625
  raise ValueError(f"{f.get('label', key)}: pick one of the models offered")
626
- if kind == "number" and (not isinstance(v, (int, float)) or isinstance(v, bool)):
627
  raise ValueError(f"{f.get('label', key)} should be a number")
 
 
628
  if kind == "number" and ((f.get("min") is not None and v < f["min"]) or (f.get("max") is not None and v > f["max"])):
629
  raise ValueError(f"{f.get('label', key)} should be between {f.get('min')} and {f.get('max')}")
630
  if kind == "bool" and not isinstance(v, bool):
 
20
  import contextlib
21
  import contextvars
22
  import json
23
+ import math
24
  import logging
25
  import re
26
  import secrets
 
29
  import time
30
  from pathlib import Path
31
  from typing import Any
32
+ from fastapi import Request
33
 
34
  from . import catalog, config, dockerfile, judge, store
35
 
 
245
 
246
 
247
  # ── the capture proxy ────────────────────────────────────────────────────────
248
+ async def _event_model_proxy(cap: str, request: Request):
249
+ from .main import llm_proxy
250
+ return await llm_proxy(cap, request)
251
+
252
+
253
+ def _event_model_health(cap: str):
254
+ from .main import llm_health
255
+ return llm_health(cap)
256
+
257
+
258
  def service() -> Any:
259
  """OpenEnv's Harbor service: the capture proxy, and the URL the sandbox reaches it at."""
260
  global _service
 
265
  # No default engine: every rollout brings its own (the visitor's token or endpoint).
266
  _service = HarborService(llm_url=config.ROUTER, model="", datasets=[], provider="hf", capture_level="text",
267
  expose=config.CAPTURE_EXPOSE)
268
+ _service.capture.app.add_api_route("/rlx/llm/{cap}/v1/chat/completions", _event_model_proxy, methods=["POST"])
269
+ _service.capture.app.add_api_route("/rlx/llm/{cap}/health", _event_model_health, methods=["GET"])
270
+ # OpenEnv ends with a catch-all POST capture route. Specific relay
271
+ # routes must precede it; they keep their own per-run capability auth.
272
+ routes = _service.capture.app.router.routes
273
+ routes[:] = routes[-2:] + routes[:-2]
274
  _patch_hf_sandbox()
275
  _patch_harbor_env()
276
  _patch_opencode_install()
 
543
  """Start a rollout of task `path` (the environment's ref) of `dataset`, with agent `harness`, by `runner`; `fields`
544
  are the run option's own inputs (contract.run_option fields), checked against it."""
545
  from . import settings
546
+ from .envs.capabilities import HOSTED_RUNNERS
547
 
548
  token = user.get("token")
549
  if not token:
550
  raise PermissionError("sign in to run a rollout")
551
+ if runner not in HOSTED_RUNNERS:
552
+ raise ValueError(f"The {runner} runtime is not hosted here. Use the task's native framework instructions.")
553
  if not settings.get("rollouts_enabled", True):
554
  raise RuntimeError("Rollouts are paused for maintenance. Try again later.")
555
  if harness not in settings.get("agents", list(AGENT_IDS)):
 
568
  if endpoint and not opt.get("endpoint", True):
569
  raise ValueError(f"the {opt['label']} runner can't use your own endpoint")
570
  fields = _check_fields(opt.get("fields") or [], fields or {})
571
+ if runner == "nemo-gym":
572
+ from . import nemo_runner
573
+ from .mimo.runner import core as events
574
+ with _slot(user["name"], max_user, max_all):
575
+ return events.submit(user["name"], token,
576
+ {"id": path, "domain": "nemo-gym", "title": row["title"], "native_task": row["native_task"]},
577
+ model, provider, None, params=fields,
578
+ visibility="private" if row.get("restricted") else visibility,
579
+ extra={"dataset": env.id, "path": path, "task_id": f"{env.id}:{path}", "env": env.key, "adapter": env.adapter.id,
580
+ "runner": runner, "harness": "single-prediction", "restricted": bool(row.get("restricted")),
581
+ "sha": row.get("sha"), "provenance": {"framework": "NeMo Gym", "commit": nemo_runner.REVISION,
582
+ "source": "https://github.com/NVIDIA-NeMo/Gym/tree/" + nemo_runner.REVISION}}, reserved=True)
583
  if runner == "mimo":
584
  return _submit_mimo(user, env, row, model, provider, endpoint, endpoint_key, fields, visibility, max_user, max_all)
585
  params = {**params, **{k: fields[k] for k in ("steps", "timeout_min") if k in fields}} # the Harbor runner's limits
 
656
 
657
  if not isinstance(v, str) or v not in {m["id"] for m in models.catalog().get(f.get("pool") or "", [])}:
658
  raise ValueError(f"{f.get('label', key)}: pick one of the models offered")
659
+ if kind == "number" and (not isinstance(v, (int, float)) or isinstance(v, bool) or not math.isfinite(v)):
660
  raise ValueError(f"{f.get('label', key)} should be a number")
661
+ if kind == "number" and f.get("step") == 1 and v != int(v):
662
+ raise ValueError(f"{f.get('label', key)} should be a whole number")
663
  if kind == "number" and ((f.get("min") is not None and v < f["min"]) or (f.get("max") is not None and v > f["max"])):
664
  raise ValueError(f"{f.get('label', key)} should be between {f.get('min')} and {f.get('max')}")
665
  if kind == "bool" and not isinstance(v, bool):
app/runtime.py CHANGED
@@ -52,7 +52,7 @@ def more_threads() -> None:
52
 
53
 
54
  # ── logs ─────────────────────────────────────────────────────────────────────
55
- SECRET_PATH = re.compile(r"(/api/llm/)[^/\s]+|((?:session|cap|token)=)[^&\s]+", re.I)
56
 
57
 
58
  class _Json(logging.Formatter):
 
52
 
53
 
54
  # ── logs ─────────────────────────────────────────────────────────────────────
55
+ SECRET_PATH = re.compile(r"(/(?:api|rlx)/llm/)[^/\s]+|((?:session|cap|token)=)[^&\s]+", re.I)
56
 
57
 
58
  class _Json(logging.Formatter):
app/search_api.py CHANGED
@@ -5,14 +5,15 @@
5
 
6
  /api/search answers exactly as the Explore page always filtered its full listing in the browser:
7
 
8
- kind what an environment is: OpenEnv Spaces (a manifest or the tag, and FineEnvs' curated servers), other
9
  environment Spaces (ORS included), Harbor datasets (tagged, or an index found task folders), Verifiers,
10
  NeMo Gym, OpenEnv datasets, verl and SkyRL, other RL datasets
 
11
  collection an admin's collection, "other" (in none) or "mine" (the signed-in visitor's datasets, with mine=1)
12
  f facet filters as the page's own URL writes them: `type:Benchmark|Neither;tags:code` (values
13
  URI-encoded); values of one facet are alternatives, facets all apply
14
  q every word must be part of the id, heading, brief or tags (substrings, any case)
15
- sort trending (pinned first; with kind=openenv, FineEnvs' curated servers next) | downloads | likes | tasks |
16
  rollouts | updated | new; ties keep the listing's order
17
 
18
  Each facet is counted over what every *other* filter leaves (so picking a value never hides its alternatives).
@@ -31,20 +32,24 @@ import re
31
  import threading
32
  import time
33
  from collections import OrderedDict
34
- from dataclasses import dataclass, field
35
  from typing import Any
36
  from urllib.parse import unquote
37
 
38
  from fastapi import APIRouter, HTTPException, Request
39
  from fastapi.responses import Response
40
 
41
- from . import snapshot
42
 
43
  router = APIRouter(on_startup=[snapshot.start])
44
 
45
- KINDS = ("all", "harbor", "openenv", "other-space", "verifiers", "nemo-gym", "openenv-data", "verl", "rows")
46
  SORTS = ("trending", "downloads", "likes", "tasks", "rollouts", "updated", "new")
47
- FACETS = ("type", "size", "stage", "mcp", "oe", "tags")
 
 
 
 
48
  OLD_KINDS = {"dataset": "harbor", "space": "openenv", "ors": "other-space"} # the page's older links
49
  FACET_LIMIT = 100 # values returned per facet (most common first); the rest are counted in facets_more
50
  MAX_WORDS = 16
@@ -55,7 +60,9 @@ MAX_VALUES = 50
55
  @dataclass
56
  class Query:
57
  q: str = ""
 
58
  kind: str = "all"
 
59
  coll: str | None = None
60
  sel: dict[str, list[str]] = field(default_factory=dict)
61
  sort: str = "trending"
@@ -67,7 +74,7 @@ class Query:
67
  return self.q.lower().split()[:MAX_WORDS]
68
 
69
  def public(self) -> dict[str, Any]:
70
- return {"q": self.q, "kind": self.kind, "collection": self.coll, "f": self.sel, "sort": self.sort,
71
  "page": self.page, "size": self.size}
72
 
73
 
@@ -79,6 +86,10 @@ def parse_f(f: str) -> dict[str, list[str]]:
79
  if k not in FACETS or not v:
80
  continue
81
  vals = [unquote(x)[:200] for x in v.split("|") if x]
 
 
 
 
82
  for x in vals:
83
  if x not in sel.setdefault(k, []) and len(sel[k]) < MAX_VALUES:
84
  sel[k].append(x)
@@ -101,7 +112,7 @@ def parse_query(params: Any, collections: list[dict[str, Any]], mine: bool) -> Q
101
  coll = g("collection", "c")
102
  known = {c["id"] for c in collections} | {"other"} | ({"mine"} if mine else set())
103
  sort = g("sort", "s") or "trending"
104
- return Query(q=(g("q") or "").strip()[:200], kind=kind if kind in KINDS else "all", coll=coll if coll in known else None,
105
  sel=parse_f(g("f") or ""), sort=sort if sort in SORTS else "trending",
106
  page=_int(g("page"), 1, 1, 10_000), size=_int(g("size"), 40, 1, 100))
107
 
@@ -155,12 +166,14 @@ class Ctx:
155
  hidden: list[str]
156
  mine: list[str]
157
  rolls: dict[str, int]
 
158
 
159
  def params(self) -> dict[str, str]:
160
  """The admin settings as query parameters: each collection's keys, pins, hidden ones, the visitor's, rollouts.
161
  (Lists checked with IN, which SQLite evaluates once per statement: no per-row JSON.)"""
162
  out = {"pins": json.dumps(self.pins), "hidden": json.dumps(self.hidden), "mine": json.dumps(self.mine),
163
- "rolls": json.dumps(self.rolls)}
 
164
  for i, g in enumerate(self.collections):
165
  out[f"c{i}"] = json.dumps([k for k, c in self.cmap.items() if c == g["id"]])
166
  out[f"cid{i}"] = g["id"]
@@ -181,32 +194,53 @@ def context(mine: list[str] | None = None) -> Ctx:
181
  for k in g.get("ids") or []:
182
  cmap.setdefault(k, g["id"]) # the first collection naming it, as catalog.collection_of
183
  return Ctx(collections=[{k: v for k, v in g.items() if k != "ids"} for g in colls], cmap=cmap, pins=list(catalog.pinned()),
184
- hidden=sorted(catalog.hidden()), mine=list(mine or []), rolls=rollout_counts())
185
 
186
 
187
  # ── SQL over the snapshot ────────────────────────────────────────────────────
188
  BASE = """
189
- WITH b0 AS (
 
 
190
  SELECT e.rowid AS rid, e.ord, e.id, e.key, e.kind, e.framework, e.openenv, e.heading, e.brief, e.downloads, e.likes,
191
  e.trending, e.created, e.updated, e.created_ms, e.updated_ms, e.stage, e.mcp, e.openenv_version, e.manifest,
192
  e.hardware, e.badges, e.tags, e.tasks, e.indexed, e.size_f, e.stage_f, e.mcp_f, e.oe_f, e.blob,
 
193
  {coll} AS coll,
194
  (e.key IN (SELECT value FROM json_each(:pins))) AS pinned,
195
  (e.key IN (SELECT value FROM json_each(:mine))) AS mine
196
  FROM envs e
 
197
  WHERE e.key NOT IN (SELECT value FROM json_each(:hidden))
198
  ),
199
- b1 AS (SELECT b0.*, (kind = 'space' AND coll IS 'fineenvs' AND framework IS NOT 'openenv') AS ov FROM b0),
200
  base AS (
201
- SELECT b1.*,
202
- CASE WHEN ov THEN 'openenv' ELSE framework END AS framework_q,
203
- CASE WHEN ov THEN 1 ELSE openenv END AS openenv_q,
204
- CASE WHEN ov THEN '["OpenEnv"]' ELSE badges END AS badges_q,
205
- CASE WHEN kind = 'space' THEN (CASE WHEN ov OR framework IS 'openenv' OR openenv THEN 'openenv' ELSE 'other-space' END)
206
- ELSE COALESCE(framework, 'harbor') END AS fw
207
- FROM b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  )
209
- """
 
210
  ROLLOUTS = "COALESCE((SELECT value FROM json_each(:rolls) WHERE key = b.id), 0)"
211
 
212
 
@@ -214,33 +248,51 @@ TYPE_VALUES = "json_each(CASE WHEN json_array_length(b.badges_q) = 0 THEN '[\"Ne
214
  FACET_FILTER = {
215
  "type": f"EXISTS (SELECT 1 FROM {TYPE_VALUES} j WHERE j.value IN (SELECT value FROM json_each({{p}})))",
216
  "size": "b.size_f IN (SELECT value FROM json_each({p}))",
217
- "stage": "b.stage_f IN (SELECT value FROM json_each({p}))",
218
- "mcp": "b.mcp_f IN (SELECT value FROM json_each({p}))",
219
- "oe": "b.oe_f IN (SELECT value FROM json_each({p}))",
 
 
 
 
 
220
  "tags": "EXISTS (SELECT 1 FROM json_each(b.tags) j WHERE j.value IN (SELECT value FROM json_each({p})))",
221
  }
222
  FACET_COUNT = { # FROM, value expression, extra condition
223
  "type": (f"base b, {TYPE_VALUES} j", "j.value", "1"),
224
  "size": ("base b", "b.size_f", "b.size_f IS NOT NULL"),
225
- "stage": ("base b", "b.stage_f", "b.stage_f IS NOT NULL"),
226
- "mcp": ("base b", "b.mcp_f", "b.mcp_f IS NOT NULL"),
227
- "oe": ("base b", "b.oe_f", "b.oe_f IS NOT NULL"),
 
 
 
 
 
228
  "tags": ("base b, json_each(b.tags) j", "j.value", "1"),
229
  }
230
- TRENDING = "((b.pinned * 1e15) + ((b.trending * 1e9 + b.likes * 1e4) + MIN(b.downloads, 9999)))"
231
  SORT_SQL = {"trending": TRENDING, "downloads": "b.downloads", "likes": "b.likes", "tasks": "COALESCE(b.tasks, -1)",
232
  "rollouts": ROLLOUTS, "updated": "b.updated_ms", "new": "b.created_ms"}
233
 
234
 
235
  def sort_sql(q: Query) -> str:
236
- if q.sort == "trending" and q.kind == "openenv": # FineEnvs' curated servers lead the OpenEnv Spaces
237
- return f"((b.coll IS 'fineenvs') * 1e14 + {TRENDING})"
238
  return SORT_SQL[q.sort]
239
 
240
 
 
 
 
 
 
 
 
 
241
  def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> tuple[str, dict[str, Any]]:
242
  parts: list[str] = []
243
  params: dict[str, Any] = {}
 
 
244
  if not ignore_kind and q.kind != "all":
245
  parts.append("b.fw = :kind")
246
  params["kind"] = q.kind
@@ -250,6 +302,9 @@ def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_k
250
  else:
251
  parts.append("COALESCE(b.coll, 'other') = :coll")
252
  params["coll"] = q.coll
 
 
 
253
  words = q.words
254
  if words:
255
  fts = envs_fts_query(words)
@@ -274,6 +329,9 @@ def card(r: Any, rolls: dict[str, int]) -> dict[str, Any]:
274
  "downloads": r["downloads"], "likes": r["likes"], "trending": r["trending"], "updated": r["updated"],
275
  "created": r["created"], "stage": r["stage"], "mcp": bool(r["mcp"]), "openenv_version": r["openenv_version"],
276
  "manifest": r["manifest"], "hardware": r["hardware"], "badges": json.loads(r["badges_q"]), "tags": json.loads(r["tags"]),
 
 
 
277
  "pinned": bool(r["pinned"]), "mine": bool(r["mine"]), "indexed": json.loads(r["indexed"]) if r["indexed"] else None,
278
  "rollouts": rolls.get(r["id"], 0), "private": False, "_sk": r["sk"], "_ord": r["ord"]}
279
 
@@ -289,7 +347,7 @@ def sql_search(conn, q: Query, ctx: Ctx, *, facets: bool = True, limit: int | No
289
  w, p = where(q)
290
  lim = q.size if limit is None else limit
291
  off = (q.page - 1) * q.size if offset is None else offset
292
- rows = conn.execute(f"{B} SELECT b.*, {sort_sql(q)} AS sk FROM base b WHERE {w} ORDER BY sk DESC, b.ord ASC LIMIT :lim OFFSET :off",
293
  {**base, **p, "lim": lim, "off": off}).fetchall()
294
  total = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE {w}", {**base, **p}).fetchone()[0]
295
  out: dict[str, Any] = {"rows": [card(r, ctx.rolls) for r in rows], "total": total}
@@ -317,22 +375,46 @@ def sql_search(conn, q: Query, ctx: Ctx, *, facets: bool = True, limit: int | No
317
  return out
318
 
319
 
320
- def sql_extra(conn, ctx: Ctx, trending: int) -> dict[str, Any]:
321
- """The page's header numbers, and the trending row (every environment, pinned first)."""
322
  base, B = ctx.params(), ctx.base()
323
- ds, sp, tasks = conn.execute(f"{B} SELECT SUM(b.kind = 'dataset'), SUM(b.kind = 'space'), SUM(COALESCE(b.tasks, 0)) FROM base b",
324
- base).fetchone()
325
- out: dict[str, Any] = {"stats": {"datasets": ds or 0, "spaces": sp or 0, "tasks": tasks or 0}}
 
 
 
 
 
 
 
 
 
 
 
326
  if trending:
327
- rows = conn.execute(f"{B} SELECT b.*, {TRENDING} AS sk FROM base b ORDER BY sk DESC, b.ord ASC LIMIT :n", {**base, "n": trending})
328
- out["trending"] = [card(r, ctx.rolls) for r in rows]
 
 
 
 
 
 
 
 
 
 
 
329
  return out
330
 
331
 
332
  # ── the same, in Python: the visitor's own datasets that aren't in the snapshot, and the tests' reference ────────
333
- def py_fw(d: dict[str, Any]) -> str:
334
  if d.get("kind") == "space":
335
- return "openenv" if d.get("framework") == "openenv" or d.get("openenv") else "other-space"
 
 
336
  return d.get("framework") or "harbor"
337
 
338
 
@@ -341,15 +423,19 @@ PY_FACETS = {
341
  "size": lambda d: [snapshot.size_bucket((d.get("indexed") or {}).get("tasks"))]
342
  if d.get("kind") == "dataset" and (d.get("framework") or "harbor") == "harbor" else [],
343
  "stage": lambda d: [snapshot.stage_value(d.get("stage"))] if d.get("kind") == "space" else [],
344
- "mcp": lambda d: (["Has MCP tools" if d.get("mcp") else "No MCP tag"]) if d.get("kind") == "space" else [],
345
- "oe": lambda d: [d["openenv_version"]] if d.get("kind") == "space" and d.get("openenv_version") else [],
 
 
 
 
 
346
  "tags": lambda d: d.get("tags") or [],
347
  }
348
 
349
 
350
  def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[str, Any]]:
351
- """Listing rows as the query sees them: collection, pins, the visitor's, rollouts, and FineEnvs' curated Spaces
352
- as OpenEnv (catalog.environments' rule). Hidden ones left out."""
353
  hidden, pins, mine = set(ctx.hidden), set(ctx.pins), set(ctx.mine)
354
  out = []
355
  for i, r in enumerate(rows):
@@ -360,9 +446,26 @@ def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[s
360
  d = {**r, "key": key, "collection": coll, "pinned": key in pins, "mine": key in mine or bool(r.get("mine")),
361
  "rollouts": ctx.rolls.get(str(r["id"]), 0), "_ord": ord0 + i, "_blob": snapshot.blob_of(r),
362
  "badges": list(r.get("badges") or []), "tags": list(r.get("tags") or [])}
363
- if d.get("kind") == "space" and coll == "fineenvs" and r.get("framework") != "openenv":
364
- d.update(framework="openenv", openenv=True, badges=["OpenEnv"])
365
- d["fw"] = py_fw(d)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  out.append(d)
367
  return out
368
 
@@ -372,22 +475,22 @@ def _time(iso: Any) -> int:
372
 
373
 
374
  def py_key(q: Query):
375
- tk = lambda d: ((d.get("trending") or 0) * 1e9 + (d.get("likes") or 0) * 1e4) + min(d.get("downloads") or 0, 9999)
376
- keys = {"trending": lambda d: (1e15 if d["pinned"] else 0) + tk(d), "downloads": lambda d: d.get("downloads") or 0,
377
  "likes": lambda d: d.get("likes") or 0, "tasks": lambda d: ((d.get("indexed") or {}).get("tasks") if (d.get("indexed") or {}).get("tasks") is not None else -1),
378
  "rollouts": lambda d: d["rollouts"], "updated": lambda d: _time(d.get("updated")), "new": lambda d: _time(d.get("created"))}
379
- k = keys[q.sort]
380
- if q.sort == "trending" and q.kind == "openenv":
381
- return lambda d: (1e14 if d.get("collection") == "fineenvs" else 0) + k(d)
382
- return k
383
 
384
 
385
  def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> bool:
386
  """The page's own `passes()`, line for line."""
 
 
387
  if not ignore_kind and q.kind != "all" and d["fw"] != q.kind:
388
  return False
389
  if not ignore_coll and q.coll and not (d["mine"] if q.coll == "mine" else (d.get("collection") or "other") == q.coll):
390
  return False
 
 
391
  if q.words and not all(w in d["_blob"] for w in q.words):
392
  return False
393
  for k, vals in q.sel.items():
@@ -401,9 +504,10 @@ def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll:
401
  def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False) -> dict[str, Any]:
402
  """`cards` (from py_cards) filtered, sorted and counted as the page did it: every match (sorted) and raw facets."""
403
  key = py_key(q)
404
- matches = sorted((d for d in cards if py_passes(d, q)), key=lambda d: (-key(d), d["_ord"]))
405
  for d in matches:
406
  d["_sk"] = key(d)
 
407
  fc: dict[str, dict[Any, int]] = {"kind": {}, "collection": {}}
408
  for d in cards:
409
  if py_passes(d, q, ignore_kind=True):
@@ -428,7 +532,8 @@ def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False)
428
  def _public_card(d: dict[str, Any]) -> dict[str, Any]:
429
  keep = ("id", "key", "kind", "framework", "fw", "openenv", "collection", "heading", "brief", "downloads", "likes",
430
  "trending", "updated", "created", "stage", "mcp", "openenv_version", "manifest", "hardware", "badges", "tags",
431
- "pinned", "mine", "indexed", "rollouts", "private")
 
432
  out = {k: d.get(k) for k in keep}
433
  out["openenv"], out["mcp"], out["private"] = bool(out["openenv"]), bool(out["mcp"]), bool(out["private"])
434
  ix = d.get("indexed")
@@ -557,7 +662,7 @@ def search(request: Request):
557
  res = sql_search(conn, q, ctx, facets=facets, limit=off + q.size, offset=0)
558
  more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
559
  ctx, ord0=10**9), q, with_mine=True)
560
- merged = sorted(res["rows"] + more["matches"], key=lambda d: (-d["_sk"], d["_ord"]))
561
  res["rows"], res["total"] = merged[off:off + q.size], res["total"] + more["total"]
562
  if facets:
563
  for k, counts in more["facets"].items():
@@ -565,15 +670,17 @@ def search(request: Request):
565
  res["facets"][k][v] = res["facets"][k].get(v, 0) + n
566
  else:
567
  res = sql_search(conn, q, ctx, facets=facets)
568
- head = sql_extra(conn, ctx, trending) if trending or facets else {}
569
  if extras and head:
570
- head["stats"]["datasets"] += len(extras)
571
- head["stats"]["tasks"] += sum((r.get("indexed") or {}).get("tasks") or 0 for r in extras)
 
 
572
  if trending:
573
- tq = Query(sort="trending")
574
  more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
575
  ctx, ord0=10**9), tq)["matches"]
576
- head["trending"] = sorted(head["trending"] + more, key=lambda d: (-d["_sk"], d["_ord"]))[:trending]
577
  info = {"built_at": snap.built_at, "source": snap.source}
578
  except snapshot.SnapshotError as e:
579
  raise HTTPException(503, f"the catalog isn't ready: {e}")
@@ -593,6 +700,11 @@ def search(request: Request):
593
  out["stats"] = head["stats"]
594
  if trending:
595
  out["trending"] = [_public_card(d) for d in head.get("trending", [])]
 
 
 
 
 
596
  out["snapshot"] = info
597
  if key is not None:
598
  with _answers_lock:
@@ -636,19 +748,18 @@ def search_tasks(request: Request, q: str = "", env: str = "", page: int = 1, si
636
 
637
 
638
  @router.get("/api/search/rank")
639
- def search_rank(key: str, request: Request):
640
- """Where one environment stands among its kind (Spaces, or datasets) by the page's trending order, pins aside:
641
  {n, of}. One query, so a page can say "#599 of 6,818 Spaces" without loading the whole listing."""
642
  ctx = context()
643
- score = "(%s.trending * 1e9 + %s.likes * 1e4 + MIN(%s.downloads, 9999))"
644
- sql = ctx.base() + f"""
645
- SELECT (SELECT COUNT(*) FROM base b WHERE b.kind = t.kind AND ({score % ('b', 'b', 'b')} > {score % ('t', 't', 't')}
646
- OR ({score % ('b', 'b', 'b')} = {score % ('t', 't', 't')} AND b.ord < t.ord))) + 1,
647
- (SELECT COUNT(*) FROM base b WHERE b.kind = t.kind)
648
- FROM base t WHERE t.key = :key"""
649
  try:
650
  with snapshot.use() as (_snap, conn):
651
- row = conn.execute(sql, {**ctx.params(), "key": key}).fetchone()
652
  except snapshot.SnapshotError as e:
653
  raise HTTPException(503, f"the catalog isn't ready: {e}")
654
  if not row:
 
5
 
6
  /api/search answers exactly as the Explore page always filtered its full listing in the browser:
7
 
8
+ kind OpenEnv Spaces require fresh successful API checks. Metadata-only matches are unverified-space; other
9
  environment Spaces (ORS included), Harbor datasets (tagged, or an index found task folders), Verifiers,
10
  NeMo Gym, OpenEnv datasets, verl and SkyRL, other RL datasets
11
+ owner exact Hub namespace, case-insensitive; applies to datasets and Spaces
12
  collection an admin's collection, "other" (in none) or "mine" (the signed-in visitor's datasets, with mine=1)
13
  f facet filters as the page's own URL writes them: `type:Benchmark|Neither;tags:code` (values
14
  URI-encoded); values of one facet are alternatives, facets all apply
15
  q every word must be part of the id, heading, brief or tags (substrings, any case)
16
+ sort trending (Hub score, then likes, then monthly dataset downloads; no editorial boosts) | downloads | likes | tasks |
17
  rollouts | updated | new; ties keep the listing's order
18
 
19
  Each facet is counted over what every *other* filter leaves (so picking a value never hides its alternatives).
 
32
  import threading
33
  import time
34
  from collections import OrderedDict
35
+ from dataclasses import dataclass, field, replace
36
  from typing import Any
37
  from urllib.parse import unquote
38
 
39
  from fastapi import APIRouter, HTTPException, Request
40
  from fastapi.responses import Response
41
 
42
+ from . import snapshot, space_checks
43
 
44
  router = APIRouter(on_startup=[snapshot.start])
45
 
46
+ KINDS = ("all", "harbor", "openenv", "unverified-space", "other-space", "verifiers", "nemo-gym", "openenv-data", "verl", "rows")
47
  SORTS = ("trending", "downloads", "likes", "tasks", "rollouts", "updated", "new")
48
+ FACETS = ("type", "size", "health", "mode", "tools", "stage", "mcp", "oe", "oe_source", "evidence", "tags")
49
+ RANKING = {"version": 2, "source": "Hugging Face Hub", "order": ["trending", "likes", "downloads"],
50
+ "description": "Hub trending score first; ties use total likes, then dataset downloads over the last month. "
51
+ "Spaces have no download metric. Remaining ties keep catalog order. Featured picks do not affect rank.",
52
+ "scope": "Current search and filters", "quality": "Interest signals, not measured environment quality or live availability."}
53
  OLD_KINDS = {"dataset": "harbor", "space": "openenv", "ors": "other-space"} # the page's older links
54
  FACET_LIMIT = 100 # values returned per facet (most common first); the rest are counted in facets_more
55
  MAX_WORDS = 16
 
60
  @dataclass
61
  class Query:
62
  q: str = ""
63
+ scope: str = "all"
64
  kind: str = "all"
65
+ owner: str | None = None
66
  coll: str | None = None
67
  sel: dict[str, list[str]] = field(default_factory=dict)
68
  sort: str = "trending"
 
74
  return self.q.lower().split()[:MAX_WORDS]
75
 
76
  def public(self) -> dict[str, Any]:
77
+ return {"q": self.q, "scope": self.scope, "owner": self.owner, "kind": self.kind, "collection": self.coll, "f": self.sel, "sort": self.sort,
78
  "page": self.page, "size": self.size}
79
 
80
 
 
86
  if k not in FACETS or not v:
87
  continue
88
  vals = [unquote(x)[:200] for x in v.split("|") if x]
89
+ if k == "stage" and "Asleep or stopped" in vals: # saved links from the former broad bucket
90
+ vals = [x for x in vals if x != "Asleep or stopped"] + ["Sleeping", "Paused", "Building", "Starting", "Error", "Deleting"]
91
+ if k == "mcp":
92
+ vals = ["MCP tagged" if x == "Has MCP tools" else x for x in vals]
93
  for x in vals:
94
  if x not in sel.setdefault(k, []) and len(sel[k]) < MAX_VALUES:
95
  sel[k].append(x)
 
112
  coll = g("collection", "c")
113
  known = {c["id"] for c in collections} | {"other"} | ({"mine"} if mine else set())
114
  sort = g("sort", "s") or "trending"
115
+ return Query(q=(g("q") or "").strip()[:200], scope="ready" if g("scope") == "ready" else "all", owner=(g("owner") or "").strip()[:200] or None, kind=kind if kind in KINDS else "all", coll=coll if coll in known else None,
116
  sel=parse_f(g("f") or ""), sort=sort if sort in SORTS else "trending",
117
  page=_int(g("page"), 1, 1, 10_000), size=_int(g("size"), 40, 1, 100))
118
 
 
166
  hidden: list[str]
167
  mine: list[str]
168
  rolls: dict[str, int]
169
+ checks: dict[str, dict] = field(default_factory=dict)
170
 
171
  def params(self) -> dict[str, str]:
172
  """The admin settings as query parameters: each collection's keys, pins, hidden ones, the visitor's, rollouts.
173
  (Lists checked with IN, which SQLite evaluates once per statement: no per-row JSON.)"""
174
  out = {"pins": json.dumps(self.pins), "hidden": json.dumps(self.hidden), "mine": json.dumps(self.mine),
175
+ "rolls": json.dumps(self.rolls), "checks": json.dumps([
176
+ {**r, "status": space_checks.status(r), "browseable": space_checks.browseable(r)} for r in self.checks.values()])}
177
  for i, g in enumerate(self.collections):
178
  out[f"c{i}"] = json.dumps([k for k, c in self.cmap.items() if c == g["id"]])
179
  out[f"cid{i}"] = g["id"]
 
194
  for k in g.get("ids") or []:
195
  cmap.setdefault(k, g["id"]) # the first collection naming it, as catalog.collection_of
196
  return Ctx(collections=[{k: v for k, v in g.items() if k != "ids"} for g in colls], cmap=cmap, pins=list(catalog.pinned()),
197
+ hidden=sorted(catalog.hidden()), mine=list(mine or []), rolls=rollout_counts(), checks=space_checks.inventory())
198
 
199
 
200
  # ── SQL over the snapshot ────────────────────────────────────────────────────
201
  BASE = """
202
+ WITH checks AS MATERIALIZED (
203
+ SELECT CAST(json_extract(value, '$.id') AS TEXT) AS id, value AS check_json FROM json_each(:checks)
204
+ ), b0 AS (
205
  SELECT e.rowid AS rid, e.ord, e.id, e.key, e.kind, e.framework, e.openenv, e.heading, e.brief, e.downloads, e.likes,
206
  e.trending, e.created, e.updated, e.created_ms, e.updated_ms, e.stage, e.mcp, e.openenv_version, e.manifest,
207
  e.hardware, e.badges, e.tags, e.tasks, e.indexed, e.size_f, e.stage_f, e.mcp_f, e.oe_f, e.blob,
208
+ c.check_json,
209
  {coll} AS coll,
210
  (e.key IN (SELECT value FROM json_each(:pins))) AS pinned,
211
  (e.key IN (SELECT value FROM json_each(:mine))) AS mine
212
  FROM envs e
213
+ LEFT JOIN checks c ON c.id = e.id AND e.kind = 'space'
214
  WHERE e.key NOT IN (SELECT value FROM json_each(:hidden))
215
  ),
 
216
  base AS (
217
+ SELECT b0.*,
218
+ CASE WHEN kind = 'space' THEN CASE WHEN {verified} THEN 'openenv' WHEN framework = 'ors' THEN 'ors' ELSE 'space' END ELSE framework END AS framework_q,
219
+ CASE WHEN kind = 'space' AND {verified} THEN 1 ELSE 0 END AS openenv_q,
220
+ CASE WHEN kind = 'space' AND NOT COALESCE(({verified}), 0) THEN
221
+ (SELECT json_group_array(value) FROM json_each(badges) WHERE value != 'OpenEnv') ELSE badges END AS badges_q,
222
+ CASE WHEN kind = 'space' THEN (CASE WHEN {verified} THEN 'openenv' WHEN framework IS 'openenv' OR openenv THEN 'unverified-space' ELSE 'other-space' END)
223
+ ELSE COALESCE(framework, 'harbor') END AS fw,
224
+ CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv)
225
+ THEN CASE WHEN manifest IS NOT NULL THEN 'Manifest present' ELSE 'Hub tag only' END END AS evidence_f,
226
+ CASE WHEN kind = 'space' THEN {stage} END AS runtime_f,
227
+ CASE WHEN kind = 'space' THEN CASE WHEN mcp THEN 'MCP tagged' ELSE 'No MCP tag' END END AS mcp_tag_f,
228
+ CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified}))
229
+ THEN COALESCE(json_extract(check_json, '$.status'), 'Not checked') END AS health_f,
230
+ CASE WHEN json_extract(check_json, '$.status') = 'API checked' THEN json_extract(check_json, '$.mode') END AS mode_f,
231
+ CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
232
+ CASE WHEN json_extract(check_json, '$.status') IN ('API checked', 'Checks failed') THEN
233
+ CASE WHEN json_extract(check_json, '$.tools') > 0 THEN 'Tools discovered' ELSE 'No tools discovered' END
234
+ ELSE 'Not checked' END END AS tools_f,
235
+ CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
236
+ COALESCE(NULLIF(json_extract(check_json, '$.version.value'), 'Unknown'), openenv_version, 'Unknown') END AS version_f,
237
+ CASE WHEN kind = 'space' AND (framework IS 'openenv' OR openenv OR ({verified})) THEN
238
+ CASE WHEN json_extract(check_json, '$.version.value') IS NOT NULL AND json_extract(check_json, '$.version.value') != 'Unknown'
239
+ THEN json_extract(check_json, '$.version.source') WHEN openenv_version IS NOT NULL THEN 'Hub tag' ELSE 'Unknown' END END AS version_source_f
240
+ FROM b0
241
  )
242
+ """.replace("{stage}", snapshot.stage_sql("stage")).replace("{verified}",
243
+ "json_extract(check_json, '$.status') = 'API checked' AND json_extract(check_json, '$.stage') = 'RUNNING'")
244
  ROLLOUTS = "COALESCE((SELECT value FROM json_each(:rolls) WHERE key = b.id), 0)"
245
 
246
 
 
248
  FACET_FILTER = {
249
  "type": f"EXISTS (SELECT 1 FROM {TYPE_VALUES} j WHERE j.value IN (SELECT value FROM json_each({{p}})))",
250
  "size": "b.size_f IN (SELECT value FROM json_each({p}))",
251
+ "stage": "b.runtime_f IN (SELECT value FROM json_each({p}))",
252
+ "health": "b.health_f IN (SELECT value FROM json_each({p}))",
253
+ "mode": "b.mode_f IN (SELECT value FROM json_each({p}))",
254
+ "tools": "b.tools_f IN (SELECT value FROM json_each({p}))",
255
+ "mcp": "b.mcp_tag_f IN (SELECT value FROM json_each({p}))",
256
+ "oe": "b.version_f IN (SELECT value FROM json_each({p}))",
257
+ "oe_source": "b.version_source_f IN (SELECT value FROM json_each({p}))",
258
+ "evidence": "b.evidence_f IN (SELECT value FROM json_each({p}))",
259
  "tags": "EXISTS (SELECT 1 FROM json_each(b.tags) j WHERE j.value IN (SELECT value FROM json_each({p})))",
260
  }
261
  FACET_COUNT = { # FROM, value expression, extra condition
262
  "type": (f"base b, {TYPE_VALUES} j", "j.value", "1"),
263
  "size": ("base b", "b.size_f", "b.size_f IS NOT NULL"),
264
+ "stage": ("base b", "b.runtime_f", "b.runtime_f IS NOT NULL"),
265
+ "health": ("base b", "b.health_f", "b.health_f IS NOT NULL"),
266
+ "mode": ("base b", "b.mode_f", "b.mode_f IS NOT NULL"),
267
+ "tools": ("base b", "b.tools_f", "b.tools_f IS NOT NULL"),
268
+ "mcp": ("base b", "b.mcp_tag_f", "b.mcp_tag_f IS NOT NULL"),
269
+ "oe": ("base b", "b.version_f", "b.version_f IS NOT NULL"),
270
+ "oe_source": ("base b", "b.version_source_f", "b.version_source_f IS NOT NULL"),
271
+ "evidence": ("base b", "b.evidence_f", "b.evidence_f IS NOT NULL"),
272
  "tags": ("base b, json_each(b.tags) j", "j.value", "1"),
273
  }
274
+ TRENDING = "b.trending"
275
  SORT_SQL = {"trending": TRENDING, "downloads": "b.downloads", "likes": "b.likes", "tasks": "COALESCE(b.tasks, -1)",
276
  "rollouts": ROLLOUTS, "updated": "b.updated_ms", "new": "b.created_ms"}
277
 
278
 
279
  def sort_sql(q: Query) -> str:
 
 
280
  return SORT_SQL[q.sort]
281
 
282
 
283
+ def order_sql(q: Query) -> str:
284
+ return "sk DESC, " + ("b.likes DESC, b.downloads DESC, " if q.sort == "trending" else "") + "b.ord ASC"
285
+
286
+
287
+ def order_key(q: Query, d: dict[str, Any]) -> tuple:
288
+ return (-d["_sk"],) + ((-(d.get("likes") or 0), -(d.get("downloads") or 0)) if q.sort == "trending" else ()) + (d["_ord"],)
289
+
290
+
291
  def where(q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> tuple[str, dict[str, Any]]:
292
  parts: list[str] = []
293
  params: dict[str, Any] = {}
294
+ if q.scope == "ready":
295
+ parts.append("(b.kind = 'dataset' OR json_extract(b.check_json, '$.browseable') = 1)")
296
  if not ignore_kind and q.kind != "all":
297
  parts.append("b.fw = :kind")
298
  params["kind"] = q.kind
 
302
  else:
303
  parts.append("COALESCE(b.coll, 'other') = :coll")
304
  params["coll"] = q.coll
305
+ if q.owner:
306
+ parts.append("substr(b.id, 1, instr(b.id, '/') - 1) = :owner COLLATE NOCASE")
307
+ params["owner"] = q.owner
308
  words = q.words
309
  if words:
310
  fts = envs_fts_query(words)
 
329
  "downloads": r["downloads"], "likes": r["likes"], "trending": r["trending"], "updated": r["updated"],
330
  "created": r["created"], "stage": r["stage"], "mcp": bool(r["mcp"]), "openenv_version": r["openenv_version"],
331
  "manifest": r["manifest"], "hardware": r["hardware"], "badges": json.loads(r["badges_q"]), "tags": json.loads(r["tags"]),
332
+ "evidence": r["evidence_f"], "stage_label": r["runtime_f"],
333
+ "api_status": r["health_f"], "api_check": json.loads(r["check_json"]) if r["check_json"] else None,
334
+ "declared_version": r["version_f"], "version_source": r["version_source_f"], "api_mode": r["mode_f"], "tools_status": r["tools_f"],
335
  "pinned": bool(r["pinned"]), "mine": bool(r["mine"]), "indexed": json.loads(r["indexed"]) if r["indexed"] else None,
336
  "rollouts": rolls.get(r["id"], 0), "private": False, "_sk": r["sk"], "_ord": r["ord"]}
337
 
 
347
  w, p = where(q)
348
  lim = q.size if limit is None else limit
349
  off = (q.page - 1) * q.size if offset is None else offset
350
+ rows = conn.execute(f"{B} SELECT b.*, {sort_sql(q)} AS sk FROM base b WHERE {w} ORDER BY {order_sql(q)} LIMIT :lim OFFSET :off",
351
  {**base, **p, "lim": lim, "off": off}).fetchall()
352
  total = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE {w}", {**base, **p}).fetchone()[0]
353
  out: dict[str, Any] = {"rows": [card(r, ctx.rolls) for r in rows], "total": total}
 
375
  return out
376
 
377
 
378
+ def sql_extra(conn, ctx: Ctx, trending: int, q: Query | None = None) -> dict[str, Any]:
379
+ """Counts, task coverage and editorial picks all follow the current search."""
380
  base, B = ctx.params(), ctx.base()
381
+ w, p = where(q or Query())
382
+ params = {**base, **p}
383
+ ds, sp = conn.execute(f"{B} SELECT SUM(b.kind = 'dataset'), SUM(b.kind = 'space') FROM base b WHERE {w}", params).fetchone()
384
+ # Count actual searchable records, including adapters (MiMo) with no Harbor
385
+ # summary. Health discovery must never imply that tasks were downloaded.
386
+ tasks = conn.execute(f"{B} SELECT COUNT(*) FROM tasks t JOIN base b ON b.key=t.env WHERE b.kind='dataset' AND {w}", params).fetchone()[0]
387
+ from . import space_tasks
388
+ visible = [r[0] for r in conn.execute(f"{B} SELECT b.id FROM base b WHERE b.kind='space' AND {w}", params)]
389
+ space_counts = space_tasks.census(ctx.checks, visible)
390
+ out: dict[str, Any] = {"stats": {"datasets": ds or 0, "spaces": sp or 0, "tasks": tasks,
391
+ "dataset_tasks": tasks, "space_tasks": space_counts["tasks"],
392
+ "space_task_coverage": space_counts}}
393
+ out["stats"]["openenv"] = dict(conn.execute(f"{B} SELECT b.evidence_f, COUNT(*) FROM base b WHERE b.evidence_f IS NOT NULL AND {w} GROUP BY 1", params))
394
+ out["stats"]["openenv_running"] = conn.execute(f"{B} SELECT COUNT(*) FROM base b WHERE b.evidence_f IS NOT NULL AND b.stage = 'RUNNING' AND {w}", params).fetchone()[0]
395
  if trending:
396
+ tq = replace(q or Query(), sort="trending", page=1, size=trending)
397
+ out["trending"] = sql_search(conn, tq, ctx, facets=False)["rows"]
398
+ w, p = where(tq)
399
+ rows = conn.execute(f"{B} SELECT b.*, 0 AS sk FROM base b WHERE b.pinned AND {w} "
400
+ "ORDER BY (SELECT CAST(key AS INTEGER) FROM json_each(:pins) WHERE value = b.key) LIMIT 12", {**base, **p})
401
+ out["featured"] = [card(r, ctx.rolls) for r in rows]
402
+ # FineEnvs is an explicit editorial section, independent of admin pins
403
+ # and interest rank. Owner/search filters still apply to it.
404
+ promoted = conn.execute(f"{B} SELECT b.*, {TRENDING} AS sk FROM base b WHERE {w} "
405
+ "AND substr(b.id, 1, instr(b.id, '/') - 1) = 'FineEnvs' COLLATE NOCASE "
406
+ "AND (b.kind = 'dataset' OR json_extract(b.check_json, '$.browseable') = 1) "
407
+ "ORDER BY (b.kind = 'space') DESC, b.pinned DESC, " + order_sql(tq) + " LIMIT 4", {**base, **p})
408
+ out["promoted"] = [card(r, ctx.rolls) for r in promoted]
409
  return out
410
 
411
 
412
  # ── the same, in Python: the visitor's own datasets that aren't in the snapshot, and the tests' reference ────────
413
+ def py_fw(d: dict[str, Any], check=None) -> str:
414
  if d.get("kind") == "space":
415
+ if space_checks.verified(check):
416
+ return "openenv"
417
+ return "unverified-space" if d.get("framework") == "openenv" or d.get("openenv") else "other-space"
418
  return d.get("framework") or "harbor"
419
 
420
 
 
423
  "size": lambda d: [snapshot.size_bucket((d.get("indexed") or {}).get("tasks"))]
424
  if d.get("kind") == "dataset" and (d.get("framework") or "harbor") == "harbor" else [],
425
  "stage": lambda d: [snapshot.stage_value(d.get("stage"))] if d.get("kind") == "space" else [],
426
+ "mcp": lambda d: (["MCP tagged" if d.get("mcp") else "No MCP tag"]) if d.get("kind") == "space" else [],
427
+ "health": lambda d: [d["api_status"]] if d.get("api_status") else [],
428
+ "mode": lambda d: [d["api_mode"]] if d.get("api_mode") else [],
429
+ "tools": lambda d: [d["tools_status"]] if d.get("tools_status") else [],
430
+ "oe": lambda d: [d["declared_version"]] if d.get("declared_version") else [],
431
+ "oe_source": lambda d: [d["version_source"]] if d.get("version_source") else [],
432
+ "evidence": lambda d: [d["evidence"]] if d.get("evidence") else [],
433
  "tags": lambda d: d.get("tags") or [],
434
  }
435
 
436
 
437
  def py_cards(rows: list[dict[str, Any]], ctx: Ctx, ord0: int = 0) -> list[dict[str, Any]]:
438
+ """Apply visibility and editorial metadata without changing framework evidence or rank."""
 
439
  hidden, pins, mine = set(ctx.hidden), set(ctx.pins), set(ctx.mine)
440
  out = []
441
  for i, r in enumerate(rows):
 
446
  d = {**r, "key": key, "collection": coll, "pinned": key in pins, "mine": key in mine or bool(r.get("mine")),
447
  "rollouts": ctx.rolls.get(str(r["id"]), 0), "_ord": ord0 + i, "_blob": snapshot.blob_of(r),
448
  "badges": list(r.get("badges") or []), "tags": list(r.get("tags") or [])}
449
+ check = ctx.checks.get(d["id"], {}) if d.get("kind") == "space" else {}
450
+ declared = d.get("kind") == "space" and (d.get("framework") == "openenv" or d.get("openenv"))
451
+ d["fw"] = py_fw(d, check)
452
+ d["evidence"] = ("Manifest present" if d.get("manifest") else "Hub tag only") if declared else None
453
+ d["stage_label"] = snapshot.stage_value(d.get("stage")) if d.get("kind") == "space" else None
454
+ health = space_checks.status(check)
455
+ candidate = declared or d["fw"] == "openenv"
456
+ ver = check.get("version") or {}
457
+ known_ver = ver.get("value") and ver["value"] != "Unknown"
458
+ d.update(api_check={**check, "status": health} if check else None, api_status=health if candidate else None,
459
+ api_mode=check.get("mode") if health == space_checks.PASS else None,
460
+ tools_status=("Tools discovered" if check.get("tools", 0) > 0 else "No tools discovered")
461
+ if candidate and health in (space_checks.PASS, "Checks failed") else "Not checked" if candidate else None,
462
+ declared_version=(ver["value"] if known_ver else d.get("openenv_version") or "Unknown") if candidate else None,
463
+ version_source=(ver.get("source") if known_ver else "Hub tag" if d.get("openenv_version") else "Unknown") if candidate else None)
464
+ if d.get("kind") == "space":
465
+ d["openenv"] = d["fw"] == "openenv"
466
+ d["framework"] = "openenv" if d["openenv"] else "ors" if d.get("framework") == "ors" else "space"
467
+ if not d["openenv"]:
468
+ d["badges"] = [b for b in d["badges"] if b != "OpenEnv"]
469
  out.append(d)
470
  return out
471
 
 
475
 
476
 
477
  def py_key(q: Query):
478
+ keys = {"trending": lambda d: d.get("trending") or 0, "downloads": lambda d: d.get("downloads") or 0,
 
479
  "likes": lambda d: d.get("likes") or 0, "tasks": lambda d: ((d.get("indexed") or {}).get("tasks") if (d.get("indexed") or {}).get("tasks") is not None else -1),
480
  "rollouts": lambda d: d["rollouts"], "updated": lambda d: _time(d.get("updated")), "new": lambda d: _time(d.get("created"))}
481
+ return keys[q.sort]
 
 
 
482
 
483
 
484
  def py_passes(d: dict[str, Any], q: Query, skip: str | None = None, ignore_coll: bool = False, ignore_kind: bool = False) -> bool:
485
  """The page's own `passes()`, line for line."""
486
+ if q.scope == "ready" and d.get("kind") == "space" and not space_checks.browseable(d.get("api_check")):
487
+ return False
488
  if not ignore_kind and q.kind != "all" and d["fw"] != q.kind:
489
  return False
490
  if not ignore_coll and q.coll and not (d["mine"] if q.coll == "mine" else (d.get("collection") or "other") == q.coll):
491
  return False
492
+ if q.owner and d["id"].split("/", 1)[0].lower() != q.owner.lower():
493
+ return False
494
  if q.words and not all(w in d["_blob"] for w in q.words):
495
  return False
496
  for k, vals in q.sel.items():
 
504
  def py_search(cards: list[dict[str, Any]], q: Query, *, with_mine: bool = False) -> dict[str, Any]:
505
  """`cards` (from py_cards) filtered, sorted and counted as the page did it: every match (sorted) and raw facets."""
506
  key = py_key(q)
507
+ matches = [d for d in cards if py_passes(d, q)]
508
  for d in matches:
509
  d["_sk"] = key(d)
510
+ matches.sort(key=lambda d: order_key(q, d))
511
  fc: dict[str, dict[Any, int]] = {"kind": {}, "collection": {}}
512
  for d in cards:
513
  if py_passes(d, q, ignore_kind=True):
 
532
  def _public_card(d: dict[str, Any]) -> dict[str, Any]:
533
  keep = ("id", "key", "kind", "framework", "fw", "openenv", "collection", "heading", "brief", "downloads", "likes",
534
  "trending", "updated", "created", "stage", "mcp", "openenv_version", "manifest", "hardware", "badges", "tags",
535
+ "pinned", "mine", "indexed", "rollouts", "private", "evidence", "stage_label", "api_status", "api_check",
536
+ "declared_version", "version_source", "api_mode", "tools_status")
537
  out = {k: d.get(k) for k in keep}
538
  out["openenv"], out["mcp"], out["private"] = bool(out["openenv"]), bool(out["mcp"]), bool(out["private"])
539
  ix = d.get("indexed")
 
662
  res = sql_search(conn, q, ctx, facets=facets, limit=off + q.size, offset=0)
663
  more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
664
  ctx, ord0=10**9), q, with_mine=True)
665
+ merged = sorted(res["rows"] + more["matches"], key=lambda d: order_key(q, d))
666
  res["rows"], res["total"] = merged[off:off + q.size], res["total"] + more["total"]
667
  if facets:
668
  for k, counts in more["facets"].items():
 
670
  res["facets"][k][v] = res["facets"][k].get(v, 0) + n
671
  else:
672
  res = sql_search(conn, q, ctx, facets=facets)
673
+ head = sql_extra(conn, ctx, trending, q) if trending or facets else {}
674
  if extras and head:
675
+ head["stats"]["datasets"] += more["total"]
676
+ private_tasks = sum((r.get("indexed") or {}).get("tasks") or 0 for r in more["matches"])
677
+ for metric in ("tasks", "dataset_tasks"):
678
+ head["stats"][metric] += private_tasks
679
  if trending:
680
+ tq = replace(q, sort="trending")
681
  more = py_search(py_cards([{**r, "key": r.get("key") or r["id"], "kind": "dataset", "mine": True} for r in extras],
682
  ctx, ord0=10**9), tq)["matches"]
683
+ head["trending"] = sorted(head["trending"] + more, key=lambda d: order_key(tq, d))[:trending]
684
  info = {"built_at": snap.built_at, "source": snap.source}
685
  except snapshot.SnapshotError as e:
686
  raise HTTPException(503, f"the catalog isn't ready: {e}")
 
700
  out["stats"] = head["stats"]
701
  if trending:
702
  out["trending"] = [_public_card(d) for d in head.get("trending", [])]
703
+ out["featured"] = [_public_card(d) for d in head.get("featured", [])]
704
+ out["promoted"] = [_public_card(d) for d in head.get("promoted", [])]
705
+ out["ranking"] = RANKING
706
+ out["api_checks"] = {"fresh_seconds": space_checks.FRESH, "description": "Running on the Hub and OpenEnv API checks passed within the last hour. "
707
+ "No episode or reward validation. Versions come from repository dependencies or Hub tags, not the installed runtime."}
708
  out["snapshot"] = info
709
  if key is not None:
710
  with _answers_lock:
 
748
 
749
 
750
  @router.get("/api/search/rank")
751
+ def search_rank(key: str, request: Request, scope: str = "all"):
752
+ """Where one environment stands among its kind (Spaces, or datasets) by the page's trending order:
753
  {n, of}. One query, so a page can say "#599 of 6,818 Spaces" without loading the whole listing."""
754
  ctx = context()
755
+ w, params = where(Query(scope="ready" if scope == "ready" else "all"))
756
+ sql = ctx.base() + f""", ranked AS (
757
+ SELECT key, ROW_NUMBER() OVER (PARTITION BY kind ORDER BY trending DESC, likes DESC, downloads DESC, ord) AS n,
758
+ COUNT(*) OVER (PARTITION BY kind) AS total FROM base b WHERE {w})
759
+ SELECT n, total FROM ranked WHERE key = :key"""
 
760
  try:
761
  with snapshot.use() as (_snap, conn):
762
+ row = conn.execute(sql, {**ctx.params(), **params, "key": key}).fetchone()
763
  except snapshot.SnapshotError as e:
764
  raise HTTPException(503, f"the catalog isn't ready: {e}")
765
  if not row:
app/seo.py CHANGED
@@ -133,6 +133,8 @@ def kind_of(row: dict[str, Any] | None, spec: str = "") -> str:
133
  if not row:
134
  return "RL environment"
135
  if row.get("kind") == "space":
 
 
136
  return {"openenv": KIND["openenv"], "ors": "ORS Space"}.get(row.get("framework") or ("openenv" if row.get("openenv") else ""), KIND["space"])
137
  return KIND.get(row.get("framework") or "", "RL dataset")
138
 
@@ -283,7 +285,7 @@ def space(request: Request, spec: str) -> HTMLResponse:
283
  "sameAs": f"https://huggingface.co/spaces/{spec}", "applicationCategory": "DeveloperApplication", "operatingSystem": "Web",
284
  "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
285
  "author": {"@type": "Organization", "name": spec.split("/")[0], "url": f"https://huggingface.co/{spec.split('/')[0]}"},
286
- "keywords": ["reinforcement learning", "RL environment", "OpenEnv", *((r or {}).get("tags") or [])[:10]]}, cld]
287
  return page(request, title=f"{name} · {kind}", description=desc, path=path, body=body, jsonld=ld, image=f"/og{path}.png" if r else None, noindex=not r)
288
 
289
 
@@ -291,7 +293,7 @@ def simple(request: Request, path: str) -> HTMLResponse:
291
  """The app's other pages: community (indexed), a rollout, your rollouts, a comparison (not indexed: they change, or are personal)."""
292
  if path.startswith("/community"):
293
  return page(request, title="Community rollouts", path="/community",
294
- description="Public rollouts of RL environments on Hugging Face, by every model: a leaderboard by environment, the traces, and the tasks nobody has tried yet.",
295
  body="<header class=\"tp-head\"><h1>Community rollouts</h1><p class=\"lede\">Public rollouts from everyone, shown without who ran them.</p></header>")
296
  titles = {"/runs": "My rollouts"}
297
  return page(request, title=titles.get(path, "Rollout" if path.startswith("/run/") else "Compare rollouts"), path=path,
 
133
  if not row:
134
  return "RL environment"
135
  if row.get("kind") == "space":
136
+ if row.get("fw") == "unverified-space":
137
+ return "Unverified Space"
138
  return {"openenv": KIND["openenv"], "ors": "ORS Space"}.get(row.get("framework") or ("openenv" if row.get("openenv") else ""), KIND["space"])
139
  return KIND.get(row.get("framework") or "", "RL dataset")
140
 
 
285
  "sameAs": f"https://huggingface.co/spaces/{spec}", "applicationCategory": "DeveloperApplication", "operatingSystem": "Web",
286
  "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"},
287
  "author": {"@type": "Organization", "name": spec.split("/")[0], "url": f"https://huggingface.co/{spec.split('/')[0]}"},
288
+ "keywords": ["reinforcement learning", "RL environment", *(["OpenEnv"] if (r or {}).get("openenv") else []), *((r or {}).get("tags") or [])[:10]]}, cld]
289
  return page(request, title=f"{name} · {kind}", description=desc, path=path, body=body, jsonld=ld, image=f"/og{path}.png" if r else None, noindex=not r)
290
 
291
 
 
293
  """The app's other pages: community (indexed), a rollout, your rollouts, a comparison (not indexed: they change, or are personal)."""
294
  if path.startswith("/community"):
295
  return page(request, title="Community rollouts", path="/community",
296
+ description="Public, graded RL rollouts across Harbor, MiMo, NeMo Gym and other supported runners. Explore traces and model results grouped by environment and scoring metric.",
297
  body="<header class=\"tp-head\"><h1>Community rollouts</h1><p class=\"lede\">Public rollouts from everyone, shown without who ran them.</p></header>")
298
  titles = {"/runs": "My rollouts"}
299
  return page(request, title=titles.get(path, "Rollout" if path.startswith("/run/") else "Compare rollouts"), path=path,
app/snapshot.py CHANGED
@@ -331,8 +331,21 @@ def size_bucket(n: int | None) -> str:
331
  return "Not indexed yet" if n is None else "Under 100" if n < 100 else "100 to 1,000" if n < 1000 else "1,000 or more"
332
 
333
 
 
 
 
 
 
 
 
 
334
  def stage_value(stage: Any) -> str:
335
- return "Running" if stage == "RUNNING" else "Asleep or stopped" if stage else "Unknown"
 
 
 
 
 
336
 
337
 
338
  def blob_of(r: dict[str, Any]) -> str:
 
331
  return "Not indexed yet" if n is None else "Under 100" if n < 100 else "100 to 1,000" if n < 1000 else "1,000 or more"
332
 
333
 
334
+ SPACE_STAGES = {
335
+ "RUNNING": "Running", "SLEEPING": "Sleeping", "PAUSED": "Paused", "STOPPED": "Paused",
336
+ "BUILDING": "Building", "APP_STARTING": "Starting", "RUNNING_APP_STARTING": "Starting",
337
+ "RUNNING_BUILDING": "Building", "BUILD_ERROR": "Error", "RUNTIME_ERROR": "Error",
338
+ "CONFIG_ERROR": "Error", "NO_APP_FILE": "Error", "DELETING": "Deleting",
339
+ }
340
+
341
+
342
  def stage_value(stage: Any) -> str:
343
+ return SPACE_STAGES.get(stage, "Unknown")
344
+
345
+
346
+ def stage_sql(column: str) -> str:
347
+ """Also classify old snapshots from their raw runtime, without trusting the old broad stage_f bucket."""
348
+ return "CASE " + column + " " + " ".join(f"WHEN '{k}' THEN '{v}'" for k, v in SPACE_STAGES.items()) + " ELSE 'Unknown' END"
349
 
350
 
351
  def blob_of(r: dict[str, Any]) -> str:
app/space_checks.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recent, non-executing OpenEnv API evidence, separate from the immutable catalog.
2
+
3
+ No wake, reset, step or tool invocation. Only public running Spaces are probed.
4
+ Per-Space atomic files work on both local storage and the mounted bucket. Search
5
+ reads a bounded cached inventory; it never waits for the network.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import threading
14
+ import time
15
+ import tomllib
16
+ from concurrent.futures import ThreadPoolExecutor
17
+
18
+ import httpx
19
+ from packaging.requirements import InvalidRequirement, Requirement
20
+ from packaging.version import InvalidVersion, Version
21
+
22
+ from . import catalog, config
23
+
24
+ FRESH = 3600
25
+ REFRESH = 1800
26
+ PASS = "API checked"
27
+ UNKNOWN = "Not checked"
28
+ _cache = (None, 0.0, {})
29
+ _lock = threading.Lock()
30
+ _stop = threading.Event()
31
+ _worker = None
32
+ log = logging.getLogger("rlx")
33
+
34
+
35
+ def _path(spec):
36
+ return config.STORAGE_DIR / "openenv-checks" / f"{catalog._slug(catalog.check_spec(spec))}.json"
37
+
38
+
39
+ def save(rec):
40
+ global _cache
41
+ path = _path(rec["id"])
42
+ with _lock:
43
+ try:
44
+ old = json.loads(path.read_bytes())
45
+ if old.get("checked_at", 0) > rec["checked_at"]:
46
+ return
47
+ except (OSError, ValueError):
48
+ pass
49
+ catalog.atomic_write(path, json.dumps(rec, separators=(",", ":")).encode())
50
+ _cache = (None, 0.0, {})
51
+
52
+
53
+ def inventory():
54
+ """Public evidence only; invalid or oversized files are ignored, never trusted."""
55
+ global _cache
56
+ directory = config.STORAGE_DIR / "openenv-checks"
57
+ with _lock:
58
+ if _cache[0] == directory and time.time() - _cache[1] < 15:
59
+ return _cache[2]
60
+ records = {}
61
+ for path in sorted(directory.glob("*.json"))[:20000]:
62
+ try:
63
+ if path.stat().st_size > 32 * 1024:
64
+ continue
65
+ r = json.loads(path.read_bytes())
66
+ spec = catalog.check_spec(r["id"])
67
+ if r.get("schema") == 1 and isinstance(r.get("checked_at"), (float, int)):
68
+ records[spec] = r
69
+ except (OSError, ValueError, KeyError, TypeError):
70
+ continue
71
+ _cache = (directory, time.time(), records)
72
+ return records
73
+
74
+
75
+ def status(rec, now=None):
76
+ if not rec:
77
+ return UNKNOWN
78
+ age = (time.time() if now is None else now) - rec.get("checked_at", 0)
79
+ if age < 0 or age >= FRESH:
80
+ return "Check expired"
81
+ return rec.get("status") if rec.get("status") in (PASS, "Checks failed", "Not running", "Check unavailable") else UNKNOWN
82
+
83
+
84
+ def verified(rec):
85
+ """Only a fresh successful check of a running server establishes API support."""
86
+ return bool(rec and rec.get("stage") == "RUNNING" and status(rec) == PASS)
87
+
88
+
89
+ def browseable(rec):
90
+ """A live, recognized environment API; this does not certify reward quality."""
91
+ return verified(rec) or bool(rec and rec.get("stage") == "RUNNING"
92
+ and status(rec) == "Checks failed"
93
+ and rec.get("interface") in ("nemo-gym", "ors"))
94
+
95
+
96
+ def environment_interface(api):
97
+ """Other supported server contracts, kept distinct from OpenEnv evidence."""
98
+ if not isinstance(api, dict) or not isinstance(api.get("info"), dict) or not api["info"].get("version"):
99
+ return None
100
+ paths = api.get("paths")
101
+ if not isinstance(paths, dict):
102
+ return None
103
+ for name, routes in (("nemo-gym", ("/seed_session", "/verify")), ("ors", ("/create_session", "/{env_name}/call"))):
104
+ if all(isinstance(paths.get(p), dict) and isinstance(paths[p].get("post"), dict) for p in routes):
105
+ return name
106
+ return None
107
+
108
+
109
+ def tag_version(tags):
110
+ for tag in tags or []:
111
+ match = re.fullmatch(r"openenv-(\d[^\s]{0,70})", str(tag), re.I)
112
+ if match:
113
+ try:
114
+ return {"value": str(Version(match[1])), "source": "Hub tag"}
115
+ except InvalidVersion:
116
+ pass
117
+ return {"value": "Unknown", "source": "Unknown"}
118
+
119
+
120
+ def requirement_version(text):
121
+ try:
122
+ req = Requirement(text)
123
+ except InvalidRequirement:
124
+ return None
125
+ if req.name.lower().replace("_", "-") not in ("openenv", "openenv-core"):
126
+ return None # current OpenEnv and the earlier openenv-core distribution
127
+ if req.marker:
128
+ return {"value": "Conditional", "source": "Dependency constraint", "requirement": str(req)[:240]}
129
+ if req.url:
130
+ ref = re.search(r"@([^/#?\s]+)(?:#.*)?$", req.url)
131
+ return {"value": "git:" + ref[1][:64] if ref else "Unpinned source", "source": "Source reference"}
132
+ specs = list(req.specifier)
133
+ if len(specs) == 1 and specs[0].operator in ("==", "===") and "*" not in specs[0].version:
134
+ try:
135
+ return {"value": str(Version(specs[0].version)), "source": "Dependency pin"}
136
+ except InvalidVersion:
137
+ pass
138
+ return {"value": str(req.specifier) or "Unpinned", "source": "Dependency constraint"}
139
+
140
+
141
+ def parse_version(text, path):
142
+ """Repository evidence, never an assertion about the installed runtime."""
143
+ if path.endswith(".toml") or path.endswith("uv.lock"):
144
+ try:
145
+ doc = tomllib.loads(text)
146
+ except (ValueError, RecursionError):
147
+ return None
148
+ if path.endswith("uv.lock"):
149
+ pins = []
150
+ for pkg in doc.get("package", []):
151
+ if not isinstance(pkg, dict) or pkg.get("name", "").replace("_", "-") not in ("openenv", "openenv-core"):
152
+ continue
153
+ source = pkg.get("source") or {}
154
+ if source.get("git"):
155
+ ref = source["git"].rsplit("#", 1)
156
+ pins.append({"value": "git:" + ref[-1][:64] if len(ref) == 2 else "Unpinned source", "source": "Lockfile"})
157
+ elif pkg.get("version"):
158
+ pins.append(requirement_version(f"openenv-core=={pkg['version']}"))
159
+ pins = [p for p in pins if p]
160
+ if len({p["value"] for p in pins}) == 1:
161
+ return {**pins[0], "source": "Lockfile"}
162
+ return {"value": "Multiple locked versions", "source": "Lockfile"} if pins else None
163
+ sources = doc.get("tool", {}).get("uv", {}).get("sources", {})
164
+ source = sources.get("openenv") or sources.get("openenv-core") or sources.get("openenv_core")
165
+ if isinstance(source, dict) and source.get("git"):
166
+ ref = source.get("rev") or source.get("tag") or source.get("branch")
167
+ return {"value": "git:" + str(ref)[:64] if ref else "Unpinned source", "source": "Source reference"}
168
+ if source:
169
+ return {"value": "Custom source", "source": "Source reference"}
170
+ deps = doc.get("project", {}).get("dependencies", [])
171
+ else:
172
+ deps = [line.split(" #", 1)[0].strip() for line in text.splitlines()]
173
+ for dep in deps:
174
+ if isinstance(dep, str) and (v := requirement_version(dep)):
175
+ return v
176
+ return None
177
+
178
+
179
+ def declared_version(spec, tags):
180
+ from . import space_files as files
181
+ fallback = tag_version(tags)
182
+ try:
183
+ info = files._info(spec)
184
+ listing, _, _ = files.listing(info)
185
+ paths = [f["path"] for f in listing]
186
+ manifest = files.manifest_path(paths, "Dockerfile" if "Dockerfile" in paths else None, spec)
187
+ root = manifest.rsplit("/", 1)[0] if manifest and "/" in manifest else ""
188
+ keys = files.key_files(paths, root, manifest)
189
+ sizes = {f["path"]: f.get("size", 0) for f in listing}
190
+ reqs = [f"{root}/requirements.txt" if root else "requirements.txt", "requirements.txt"]
191
+ for path in dict.fromkeys([keys.get("lock"), keys.get("pyproject"), *reqs]):
192
+ if not path or path not in sizes or sizes[path] > 400 * 1024:
193
+ continue
194
+ text = files._fetch_text(spec, info["sha"], path, sizes[path])
195
+ if text and (v := parse_version(text, path)):
196
+ return {**v, "path": path, "revision": info["sha"]}
197
+ except Exception: # incomplete version evidence must not change API health
198
+ pass
199
+ return fallback
200
+
201
+
202
+ def assess(api, health, metadata, schema, tools):
203
+ api = api if isinstance(api, dict) else {}
204
+ paths = api.get("paths") if isinstance(api.get("paths"), dict) else {}
205
+ def method(path, verb):
206
+ return isinstance(paths.get(path), dict) and isinstance(paths[path].get(verb), dict)
207
+ checks = {
208
+ "OpenAPI": isinstance(api.get("info"), dict) and isinstance(api["info"].get("version"), str),
209
+ "Health": isinstance(health, dict) and health.get("status") == "healthy",
210
+ "Metadata": isinstance(metadata, dict) and all(isinstance(metadata.get(k), str) for k in ("name", "description")),
211
+ "Schemas": isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state")),
212
+ }
213
+ simulation = any(p in paths for p in ("/reset", "/step", "/state"))
214
+ mode = "Simulation" if simulation else "MCP service"
215
+ checks["Control routes" if simulation else "MCP tools"] = (
216
+ all(method(p, v) for p, v in (("/reset", "post"), ("/step", "post"), ("/state", "get")))
217
+ if simulation else method("/mcp", "post") and bool(tools))
218
+ return {"status": PASS if all(checks.values()) else "Checks failed", "mode": mode if all(checks.values()) else None,
219
+ "failed": [k for k, ok in checks.items() if not ok], "tools": len(tools or [])}
220
+
221
+
222
+ def check(spec):
223
+ from . import spaces_live as live
224
+ rec = {"schema": 1, "id": catalog.check_spec(spec), "checked_at": time.time(), "status": "Check unavailable"}
225
+ previous = inventory().get(spec, {})
226
+ rec["version"] = previous.get("version") or {"value": "Unknown", "source": "Unknown"}
227
+ try:
228
+ hub = live.record(spec, fresh=True)
229
+ rec["stage"] = hub["stage"]
230
+ if hub["stage"] != "RUNNING":
231
+ rec["status"] = "Not running"
232
+ elif hub.get("host"):
233
+ urls = ("/openapi.json", "/health", "/metadata", "/schema")
234
+ def get(path):
235
+ try:
236
+ with live.SpaceClient(timeout=httpx.Timeout(6, connect=4), follow_redirects=False) as client:
237
+ response = client.get(live._url(hub, path))
238
+ return response.json() if response.status_code == 200 and "json" in response.headers.get("content-type", "") else None
239
+ except (httpx.HTTPError, ValueError, live.SpaceError):
240
+ return None
241
+ with ThreadPoolExecutor(4) as pool:
242
+ api, health, meta, schema = list(pool.map(get, urls))
243
+ # MCP discovery is separate from typed-action OpenEnv compatibility.
244
+ paths = api.get("paths", {}) if isinstance(api, dict) else {}
245
+ typed = isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state"))
246
+ tools, _, _ = live._tools({**hub, "mcp_standard": not typed}) if isinstance(paths, dict) and "/mcp" in paths else (None, None, None)
247
+ rec.update(assess(api, health, meta, schema, tools))
248
+ rec["interface"] = environment_interface(api)
249
+ from . import space_tasks
250
+ task_api = space_tasks.discover(hub, paths)
251
+ rec["task_catalog"] = space_tasks.summary(task_api)
252
+ if rec["status"] == PASS:
253
+ rec["version"] = declared_version(spec, hub.get("tags"))
254
+ elif rec["version"]["value"] == "Unknown":
255
+ rec["version"] = tag_version(hub.get("tags"))
256
+ except Exception as exc:
257
+ rec["error"] = type(exc).__name__ # no upstream response/secret in public inventory
258
+ save(rec)
259
+ return rec
260
+
261
+
262
+ def observe(spec, info):
263
+ """Opening/rechecking a Space also updates discovery, including negative results."""
264
+ try:
265
+ old = inventory().get(spec, {})
266
+ rec = {"schema": 1, "id": spec, "checked_at": info.get("checked") or time.time(), "stage": info.get("stage"),
267
+ "version": old.get("version") or {"value": "Unknown", "source": "Unknown"}}
268
+ if not info.get("running"):
269
+ rec["status"] = "Not running"
270
+ else:
271
+ rec.update(assess({"info": {"version": info.get("openapi_version")}, "paths": info.get("endpoint_methods")},
272
+ info.get("health"), info.get("metadata"), info.get("schema"), info.get("mcp")))
273
+ rec["interface"] = environment_interface({"info": {"version": info.get("openapi_version")}, "paths": info.get("endpoint_methods")})
274
+ from . import space_tasks
275
+ rec["task_catalog"] = space_tasks.summary(info.get("task_api"))
276
+ info["openenv_verified"] = verified(rec)
277
+ save(rec)
278
+ except Exception:
279
+ log.warning("Could not save OpenEnv check for %s", spec)
280
+
281
+
282
+ def scan():
283
+ # App workers and an operator's CLI refresh share one bounded probe queue.
284
+ # Use local cache storage for this lock, never a mounted object-store bucket.
285
+ try:
286
+ import fcntl
287
+ except ImportError: # Windows development: the app still has one worker thread
288
+ return _scan()
289
+ config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
290
+ with (config.CACHE_DIR / "openenv-checks.lock").open("a") as lease:
291
+ try:
292
+ fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
293
+ except BlockingIOError:
294
+ return []
295
+ return _scan()
296
+
297
+
298
+ def _scan():
299
+ from . import snapshot
300
+ old = inventory()
301
+ with snapshot.use() as (_, conn):
302
+ candidates = list(conn.execute("SELECT id, stage FROM envs WHERE kind = 'space' ORDER BY trending DESC, likes DESC"))
303
+ due = pending(candidates, old)
304
+ def visit(spec):
305
+ if not _stop.is_set():
306
+ return check(spec)
307
+ with ThreadPoolExecutor(4) as pool:
308
+ return [r for r in pool.map(visit, due) if r]
309
+
310
+
311
+ def pending(candidates, old, now=None):
312
+ """Bound the queue and reserve capacity for inactive candidates too.
313
+
314
+ Never probing sleeping hosts, but refreshing their Hub metadata lets a
315
+ newly started Space enter the catalog without someone opening its page.
316
+ """
317
+ now = time.time() if now is None else now
318
+ active, inactive = [], []
319
+ for spec, stage in candidates:
320
+ rec = old.get(spec, {})
321
+ running = rec.get("stage", stage) == "RUNNING"
322
+ at = rec.get("checked_at", 0)
323
+ interval = REFRESH if running else 6 * 3600
324
+ if not rec or now - at >= interval or (running and status(rec, now) == PASS and "task_catalog" not in rec):
325
+ (active if running else inactive).append((spec, at))
326
+ # Once checked, older entries take precedence so a failing popular Space
327
+ # cannot starve the long tail. FineEnvs goes first within each generation.
328
+ key = lambda item: (int(item[1] // REFRESH), item[0].split("/", 1)[0].lower() != "fineenvs", item[1])
329
+ active.sort(key=key)
330
+ inactive.sort(key=key)
331
+ return [s for s, _ in active[:96] + inactive[:32]]
332
+
333
+
334
+ def start():
335
+ global _worker
336
+ if os.environ.get("RLX_TEST_TMP") or os.environ.get("RLX_SPACE_CHECKS", "1") != "1" or (_worker and _worker.is_alive()):
337
+ return
338
+ _stop.clear()
339
+ def watch():
340
+ while not _stop.is_set():
341
+ try:
342
+ scan()
343
+ except Exception:
344
+ log.exception("OpenEnv API inventory refresh failed")
345
+ _stop.wait(60)
346
+ _worker = threading.Thread(target=watch, daemon=True, name="openenv-checks")
347
+ _worker.start()
348
+
349
+
350
+ def stop():
351
+ _stop.set()
352
+
353
+
354
+ if __name__ == "__main__":
355
+ from collections import Counter
356
+ from . import runtime
357
+ runtime.hub_timeouts()
358
+ print(dict(Counter(r["status"] for r in scan())), flush=True)
app/space_tasks.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded discovery of the public OpenEnv Task API, without starting episodes.
2
+
3
+ These are server-reported catalog sizes, not downloaded task records or proof that
4
+ an episode works. Keep their own timestamp so a health check cannot freshen them.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import re
9
+ import time
10
+ from concurrent.futures import ThreadPoolExecutor
11
+
12
+ MAX_COUNT = 1_000_000_000
13
+ FRESH = 3600
14
+
15
+
16
+ def count(value):
17
+ return value if type(value) is int and 0 <= value <= MAX_COUNT else None
18
+
19
+
20
+ def discover(rec, paths, envs=None):
21
+ from . import spaces_live as live
22
+
23
+ if "/{env_name}/splits" not in paths:
24
+ return None
25
+ if envs is None:
26
+ envs = live._get_json(rec, "/list_environments")
27
+ if not isinstance(envs, list):
28
+ return None
29
+ names = list(dict.fromkeys(e for e in envs if isinstance(e, str) and re.fullmatch(r"[\w.-]{1,80}", e) and e not in (".", "..")))
30
+
31
+ def read(name):
32
+ raw = live._get_json(rec, f"/{name}/splits")
33
+ splits, seen = [], set()
34
+ for s in raw[:40] if isinstance(raw, list) else []:
35
+ if not isinstance(s, dict) or not isinstance(s.get("name"), str) or len(s["name"]) > 200 or s["name"] in seen:
36
+ continue
37
+ seen.add(s["name"])
38
+ n = count(s.get("num_tasks"))
39
+ if n is None and "/{env_name}/num_tasks" in paths and len(splits) < 12:
40
+ try:
41
+ r = live._client.post(live._url(rec, f"/{name}/num_tasks"), json={"split": s["name"]}, timeout=6)
42
+ doc = live._json(r) if r.status_code == 200 else None
43
+ n = count(doc.get("num_tasks")) if isinstance(doc, dict) else None
44
+ except Exception:
45
+ pass
46
+ splits.append({"name": s["name"], "type": s.get("type"), "num_tasks": n, "default": s.get("default") is True})
47
+ return {"env": name, "splits": splits, "complete": isinstance(raw, list) and len(raw) <= 40 and len(seen) == len(raw)}
48
+
49
+ with ThreadPoolExecutor(4) as pool:
50
+ environments = list(pool.map(read, names[:16]))
51
+ if not environments:
52
+ return None
53
+ return {"env": environments[0]["env"], "splits": environments[0]["splits"], "environments": environments,
54
+ "complete": len(names) <= 16 and all(e["complete"] for e in environments), "checked_at": time.time()}
55
+
56
+
57
+ def summary(task_api):
58
+ if not isinstance(task_api, dict):
59
+ return None
60
+ envs = task_api.get("environments") or [task_api]
61
+ entries, unknown, seen = [], 0, set()
62
+ for env in envs[:16]:
63
+ for split in env.get("splits", [])[:40]:
64
+ key = (env.get("env"), split.get("name"))
65
+ if key in seen:
66
+ continue
67
+ seen.add(key)
68
+ n = count(split.get("num_tasks"))
69
+ if n is None:
70
+ unknown += 1
71
+ else:
72
+ entries.append(n)
73
+ return {"tasks": sum(entries), "counted_splits": len(entries), "unknown_splits": unknown,
74
+ "complete": bool(task_api.get("complete", True)) and not unknown,
75
+ "checked_at": task_api.get("checked_at") or time.time()}
76
+
77
+
78
+ def census(records, visible, now=None):
79
+ now = time.time() if now is None else now
80
+ out = {"tasks": 0, "spaces": 0, "partial_spaces": 0, "oldest_check": None}
81
+ for spec in visible:
82
+ rec = records.get(spec, {})
83
+ s = rec.get("task_catalog") or {}
84
+ at = s.get("checked_at", 0)
85
+ if rec.get("stage") != "RUNNING" or not isinstance(at, (int, float)) or not 0 <= now - at < FRESH or not s.get("counted_splits"):
86
+ continue
87
+ n = s.get("tasks")
88
+ if type(n) is not int or not 0 <= n <= MAX_COUNT * 640:
89
+ continue
90
+ out["tasks"] += n
91
+ out["spaces"] += 1
92
+ out["partial_spaces"] += not s.get("complete")
93
+ out["oldest_check"] = min(out["oldest_check"] or at, at)
94
+ return out
app/spaces_live.py CHANGED
@@ -20,6 +20,8 @@ import re
20
  import secrets
21
  import threading
22
  import time
 
 
23
  from concurrent.futures import ThreadPoolExecutor
24
  from typing import Any
25
 
@@ -38,9 +40,6 @@ STANDARD_ROUTES = {"/reset", "/step", "/state", "/metadata", "/health", "/health
38
  "/{env_name}/splits", "/{env_name}/tasks", "/{env_name}/num_tasks", "/{env_name}/task", "/{env_name}/task_range",
39
  "/web/metadata", "/web/reset", "/web/step", "/web/state", "/docs", "/redoc", "/openapi.json"}
40
  MAX_REPLY = 12 * 2**20 # bytes from a Space for one reply (observations can carry images)
41
- _client = httpx.Client(timeout=httpx.Timeout(12, connect=6), follow_redirects=False,
42
- headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"},
43
- limits=httpx.Limits(max_connections=64, max_keepalive_connections=16))
44
  _lock = threading.Lock()
45
 
46
 
@@ -52,6 +51,48 @@ class SpaceError(Exception):
52
  self.status = status
53
 
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  # ── the Space's record ───────────────────────────────────────────────────────
56
  def record(spec: str, fresh: bool = False) -> dict[str, Any]:
57
  """Host, stage and declared UI path of a public Space (cached a few seconds: the page polls it while it wakes)."""
@@ -99,7 +140,7 @@ def _get_here(rec: dict, path: str, hops: int = 3) -> httpx.Response | None:
99
  if r.status_code not in (301, 302, 303, 307, 308):
100
  return r
101
  nxt = r.url.join(r.headers.get("location", ""))
102
- if nxt.scheme != "https" or nxt.host != httpx.URL(rec["host"]).host:
103
  return None
104
  url = str(nxt)
105
  return None
@@ -133,6 +174,13 @@ def _get_json(rec: dict, path: str) -> Any:
133
  def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float = 30, raw: bool = False) -> Any:
134
  """One JSON-RPC call to the Space's /mcp. Returns `result`; raises SpaceError with the server's message on error.
135
  `raw`: the whole JSON-RPC reply, an error reply included (HTTP failures still raise)."""
 
 
 
 
 
 
 
136
  try:
137
  r = _client.post(_url(rec, "/mcp"), timeout=httpx.Timeout(timeout, connect=6),
138
  headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"},
@@ -157,6 +205,100 @@ def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float
157
  return doc.get("result")
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def _tools(rec: dict) -> tuple[list[dict] | None, str | None, dict]:
161
  """Its MCP tools (or why there are none), and how /mcp answered: a JSON-RPC 2.0 reply, as `openenv validate`
162
  asks of it, or the failure."""
@@ -182,8 +324,6 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
182
  own plumbing (geoguesser's /geoguesser/task/{i} returns the answer's coordinates). GET and POST only."""
183
  if openenv or not isinstance(api, dict) or not isinstance(api.get("paths"), dict):
184
  return []
185
- comps = ((api.get("components") or {}).get("schemas") or {}) if isinstance(api.get("components"), dict) else {}
186
-
187
  def resolve(x: Any, depth: int = 0) -> Any:
188
  if not isinstance(x, (dict, list)):
189
  return x
@@ -191,7 +331,12 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
191
  return {} if isinstance(x, dict) else []
192
  if isinstance(x, dict):
193
  if "$ref" in x and isinstance(x["$ref"], str):
194
- return resolve(comps.get(x["$ref"].split("/")[-1], {}), depth + 1)
 
 
 
 
 
195
  return {k: resolve(v, depth + 1) for k, v in x.items() if k != "$defs"}
196
  if isinstance(x, list):
197
  return [resolve(v, depth + 1) for v in x[:50]]
@@ -203,7 +348,9 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
203
  if (not isinstance(ops, dict) or not isinstance(path, str) or not re.match(r"^/(?!/)[\w\-.~{}/]{0,199}$", path)
204
  or ".." in path):
205
  continue
206
- if path in STANDARD_ROUTES or path.startswith(("/web", "/login", "/logout", "/docs", "/redoc", "/static", "/gradio", "/assets")):
 
 
207
  continue
208
  if openenv and path in ("/healthz",):
209
  continue
@@ -213,15 +360,24 @@ def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]:
213
  continue
214
  body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema")
215
  schema = resolve(body) if body else None
 
 
 
 
 
 
 
 
216
  out.append({"method": method.upper(), "path": path, "summary": str(op.get("summary") or op.get("description") or "")[:200],
217
  "params": re.findall(r"\{([A-Za-z_][\w-]{0,40})\}", path),
218
- "schema": schema if isinstance(schema, dict) and schema.get("type") == "object" else None})
 
219
  if len(out) >= 40:
220
  break
221
  return out
222
 
223
 
224
- def route_url(info: dict, method: str, path: str, params: dict[str, Any]) -> str:
225
  """A route the server published, its path parameters filled in safely; anything else is refused."""
226
  route = next((r for r in info.get("api") or [] if r["method"] == method and r["path"] == path), None)
227
  if route is None:
@@ -229,9 +385,29 @@ def route_url(info: dict, method: str, path: str, params: dict[str, Any]) -> str
229
  filled = path
230
  for name in route["params"]:
231
  v = str((params or {}).get(name, ""))
232
- if not re.match(r"^[\w.\-~]{1,200}$", v):
233
  raise SpaceError(f"{name}: letters, digits, . - _ ~ only", 400)
234
  filled = filled.replace("{" + name + "}", v)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return filled
236
 
237
 
@@ -239,8 +415,12 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
239
  """Everything a running server says about itself, in one record. Asleep or broken: just the stage."""
240
  rec = record(spec, fresh=fresh)
241
  if rec["stage"] != UP or not rec["host"]:
242
- return {"running": False, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
 
243
  "last_seen": last_seen(rec["id"])}
 
 
 
244
 
245
  def fetch():
246
  with ThreadPoolExecutor(6) as pool:
@@ -253,23 +433,11 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
253
  api, meta, schema, health, envs, ui = (f.result() for f in (f_api, f_meta, f_schema, f_health, f_envs, f_ui))
254
  routes = sorted((api or {}).get("paths", {}).keys()) if isinstance(api, dict) else []
255
  has = set(routes)
256
- openenv = isinstance(schema, dict) and ("action" in schema or "observation" in schema)
257
- tools, tools_error, rpc = _tools(rec) if ("/mcp" in has or openenv) else (None, None, None)
258
  env_names = [e for e in envs if isinstance(e, str) and re.match(r"^[\w.-]{1,80}$", e)] if isinstance(envs, list) else []
259
- task_api = bool(env_names) and "/{env_name}/splits" in has
260
- splits = []
261
- if task_api:
262
- raw = _get_json(rec, f"/{env_names[0]}/splits")
263
- for s in (raw or [])[:40] if isinstance(raw, list) else []:
264
- if isinstance(s, dict) and isinstance(s.get("name"), str):
265
- splits.append({"name": s["name"], "type": s.get("type"), "num_tasks": s.get("num_tasks"), "default": s.get("default")})
266
- missing = [s for s in splits if not isinstance(s["num_tasks"], int)][:12]
267
- for s in missing:
268
- try:
269
- r = _client.post(_url(rec, f"/{env_names[0]}/num_tasks"), json={"split": s["name"]}, timeout=8)
270
- s["num_tasks"] = int(_json(r).get("num_tasks")) if r.status_code == 200 else None
271
- except Exception: # noqa: BLE001
272
- s["num_tasks"] = None
273
  names = {t["name"] for t in tools or []}
274
  harbor = {"run_rollout", "capabilities"} <= names or "harbor_env" in env_names
275
  caps = _harbor_caps(rec) if "capabilities" in names and harbor else None
@@ -277,30 +445,37 @@ def probe(spec: str, fresh: bool = False) -> dict[str, Any]:
277
  version = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None
278
  info = {
279
  "running": True, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
280
- "ui": ui, "openenv": openenv or "/reset" in has,
281
  "metadata": meta if isinstance(meta, dict) else None, "health": health if isinstance(health, dict) else None,
282
  "schema": schema if isinstance(schema, dict) else None,
283
- "step_api": "/reset" in has and "/step" in has, "docs": "/openapi.json" in has or api is not None,
284
  "mcp": tools, "mcp_error": None if tools else tools_error,
285
- "task_api": {"env": env_names[0], "splits": splits} if task_api else None,
286
  "harbor": harbor, "harbor_caps": caps,
287
  "rewards": bool(isinstance(obs, dict) and "reward" in (obs.get("properties") or {})),
288
  "graders": sorted(n for n in names if re.search(r"(grade|score|submit|verify|reward|judge)", n, re.I)),
289
  "routes": [r for r in routes if r not in STANDARD_ROUTES][:40],
290
- "api": _api_routes(api, openenv or "/reset" in has),
291
  # which framework the server speaks, from what it serves
292
- "framework": "harbor" if harbor else "openenv" if (openenv or "/reset" in has) else
293
  "nemo-gym" if {"/seed_session", "/verify"} <= has else
294
- "ors" if {"/create_session", "/{env_name}/call"} <= has else "mcp" if tools else "api" if api else None,
 
295
  "openapi_version": version if isinstance(version, str) else None,
 
 
296
  # OpenEnv's two modes: simulation serves /reset, /step and /state; production serves only MCP and /ws
297
- "mode": ("simulation" if "/reset" in has else "production") if routes else None,
298
  "conformance": conformance({"openapi": api, "health": health, "metadata": meta, "schema": schema, "mcp": rpc,
299
  "mcp_tried": "/mcp" in has or openenv, "paths": routes}),
300
  "checked": time.time(),
301
  }
302
  if api is not None or isinstance(schema, dict) or isinstance(meta, dict) or tools: # it answered: keep it
303
  remember(rec["id"], info)
 
 
 
 
304
  return info
305
 
306
  if fresh:
@@ -518,17 +693,20 @@ def _withhold(v: Any, depth: int = 0) -> tuple[Any, list[str]]:
518
  return v, gone
519
 
520
 
521
- def _task_api(spec: str) -> tuple[dict, str]:
522
  info = probe(spec)
523
  if not info.get("running"):
524
  raise SpaceError("this Space isn't running: wake it first", 409)
525
  if not info.get("task_api"):
526
  raise SpaceError("this server has no Task API", 404)
527
- return record(spec), info["task_api"]["env"]
 
 
 
528
 
529
 
530
- def tasks(spec: str, split: str, start: int, stop: int) -> dict[str, Any]:
531
- rec, env = _task_api(spec)
532
  start, stop = max(0, int(start)), max(0, int(stop))
533
  stop = min(stop, start + 50)
534
  try:
@@ -542,8 +720,8 @@ def tasks(spec: str, split: str, start: int, stop: int) -> dict[str, Any]:
542
  return {"tasks": rows, "start": start, "stop": stop, "withheld": sorted(set(gone))}
543
 
544
 
545
- def task(spec: str, split: str, index: int) -> dict[str, Any]:
546
- rec, env = _task_api(spec)
547
  try:
548
  r = _client.post(_url(rec, f"/{env}/task"), json={"split": split, "index": int(index)}, timeout=20)
549
  except httpx.HTTPError as e:
@@ -570,12 +748,18 @@ class Session:
570
  self.created = self.used = time.time()
571
  self.ws = None
572
  self.ws_failed = False
 
573
  self.http: httpx.Client | None = None # the session's own cookie jar for the server's routes (NeMo Gym keeps state in a cookie)
 
 
574
  self.lock = threading.Lock()
575
  self.steps = 0
576
  self.rpc = 0
577
 
578
  def close(self) -> None:
 
 
 
579
  if self.http is not None:
580
  try:
581
  self.http.close()
@@ -625,6 +809,8 @@ def start(spec: str, owner: str) -> dict[str, Any]:
625
  if len(_sessions) >= TOTAL:
626
  raise SpaceError("too many live sessions on this explorer right now: try again in a few minutes", 429)
627
  s = Session(catalog.check_spec(spec), owner)
 
 
628
  _sessions[s.id] = s
629
  return {"session": s.id}
630
 
@@ -660,6 +846,10 @@ def _open(s: Session) -> None:
660
 
661
 
662
  def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
 
 
 
 
663
  if s.ws is None:
664
  _open(s)
665
  try:
@@ -667,9 +857,11 @@ def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
667
  reply = json.loads(s.ws.recv(timeout=timeout))
668
  except TimeoutError:
669
  s.close()
 
670
  raise SpaceError(f"the Space didn't answer within {int(timeout)} s; the session was closed", 504) from None
671
  except Exception as e: # noqa: BLE001 - the socket dropped: the episode is gone
672
  s.close()
 
673
  raise SpaceError(f"the session dropped ({type(e).__name__}): start a new episode", 502) from None
674
  if not isinstance(reply, dict):
675
  raise SpaceError("the Space answered something unexpected", 502)
@@ -680,7 +872,7 @@ def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
680
 
681
 
682
  def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
683
- """An MCP call inside the session's episode; over plain HTTP (stateless) when the server has no socket for it."""
684
  if not s.ws_failed:
685
  try:
686
  s.rpc += 1
@@ -696,6 +888,12 @@ def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
696
  err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])}
697
  raise SpaceError(str(err.get("message") or "error")[:600], 422)
698
  return data.get("result") if isinstance(data, dict) else data
 
 
 
 
 
 
699
  return mcp_call(record(s.spec), method, params, timeout=timeout)
700
 
701
 
@@ -705,14 +903,15 @@ def _http(s: Session, data: dict) -> dict[str, Any]:
705
  if method not in ("GET", "POST"):
706
  raise SpaceError("GET or POST only", 400)
707
  info = probe(s.spec)
708
- path = route_url(info, method, str(data.get("path") or ""), data.get("params") if isinstance(data.get("params"), dict) else {})
 
709
  rec = record(s.spec)
710
  if s.http is None:
711
- s.http = httpx.Client(base_url=rec["host"], timeout=httpx.Timeout(120, connect=8), follow_redirects=False,
712
  headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"})
713
  body = data.get("body")
714
  try:
715
- r = s.http.request(method, path, json=body if method == "POST" else None)
716
  except httpx.TimeoutException:
717
  raise SpaceError("the Space didn't answer within 120 s", 504) from None
718
  except httpx.HTTPError as e:
@@ -773,7 +972,8 @@ def act(sid: str, owner: str, op: str, data: dict | None = None) -> dict[str, An
773
  s.steps += 1
774
  else:
775
  raise SpaceError(f"unknown operation {op!r}", 400)
776
- return {"result": out, "ms": int((time.time() - t0) * 1000), "steps": s.steps, "stateful": not s.ws_failed}
 
777
 
778
 
779
  def live_sessions() -> int:
 
20
  import secrets
21
  import threading
22
  import time
23
+ import zlib
24
+ from urllib.parse import urlencode
25
  from concurrent.futures import ThreadPoolExecutor
26
  from typing import Any
27
 
 
40
  "/{env_name}/splits", "/{env_name}/tasks", "/{env_name}/num_tasks", "/{env_name}/task", "/{env_name}/task_range",
41
  "/web/metadata", "/web/reset", "/web/step", "/web/state", "/docs", "/redoc", "/openapi.json"}
42
  MAX_REPLY = 12 * 2**20 # bytes from a Space for one reply (observations can carry images)
 
 
 
43
  _lock = threading.Lock()
44
 
45
 
 
51
  self.status = status
52
 
53
 
54
+ class SpaceClient(httpx.Client):
55
+ """Bound decompressed replies while receiving them, including chunked bodies.
56
+
57
+ Space operators control their responses. Checking Response.content after a
58
+ normal get() is too late: httpx has already buffered the complete response.
59
+ Keep httpx's cookie jar and connection pooling, but never buffer without a cap.
60
+ """
61
+
62
+ def send(self, request, *, stream=False, **kwargs):
63
+ # Negotiate only a codec with a bounded decompression API.
64
+ request.headers["Accept-Encoding"] = "gzip, identity"
65
+ response = super().send(request, stream=True, **kwargs)
66
+ try:
67
+ body = bytearray()
68
+ codec = response.headers.get("content-encoding", "identity").lower()
69
+ decoded = response.is_stream_consumed
70
+ if not decoded and codec not in {"identity", "gzip"}:
71
+ raise SpaceError("the Space used an unsupported response encoding", 502)
72
+ decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) if not decoded and codec == "gzip" else None
73
+ chunks = (response.content,) if decoded else response.iter_raw(chunk_size=64 * 1024)
74
+ for chunk in chunks:
75
+ if decoder:
76
+ chunk = decoder.decompress(chunk, MAX_REPLY - len(body) + 1)
77
+ if len(body) + len(chunk) > MAX_REPLY:
78
+ raise SpaceError("the Space's reply is too large to show", 502)
79
+ body.extend(chunk)
80
+ if decoder and (not decoder.eof or decoder.unused_data):
81
+ raise SpaceError("the Space returned an invalid compressed response", 502)
82
+ headers = [(k, v) for k, v in response.headers.multi_items()
83
+ if k.lower() not in ("content-encoding", "content-length", "transfer-encoding")]
84
+ return httpx.Response(response.status_code, headers=headers, content=bytes(body), request=request)
85
+ except zlib.error:
86
+ raise SpaceError("the Space returned an invalid compressed response", 502) from None
87
+ finally:
88
+ response.close()
89
+
90
+
91
+ _client = SpaceClient(timeout=httpx.Timeout(12, connect=6), follow_redirects=False,
92
+ headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"},
93
+ limits=httpx.Limits(max_connections=64, max_keepalive_connections=16))
94
+
95
+
96
  # ── the Space's record ───────────────────────────────────────────────────────
97
  def record(spec: str, fresh: bool = False) -> dict[str, Any]:
98
  """Host, stage and declared UI path of a public Space (cached a few seconds: the page polls it while it wakes)."""
 
140
  if r.status_code not in (301, 302, 303, 307, 308):
141
  return r
142
  nxt = r.url.join(r.headers.get("location", ""))
143
+ if nxt.scheme != "https" or nxt.host != httpx.URL(rec["host"]).host or nxt.port not in (None, 443) or nxt.userinfo:
144
  return None
145
  url = str(nxt)
146
  return None
 
174
  def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float = 30, raw: bool = False) -> Any:
175
  """One JSON-RPC call to the Space's /mcp. Returns `result`; raises SpaceError with the server's message on error.
176
  `raw`: the whole JSON-RPC reply, an error reply included (HTTP failures still raise)."""
177
+ if rec.get("mcp_standard"):
178
+ with SpaceClient(timeout=httpx.Timeout(timeout, connect=6), follow_redirects=False) as client:
179
+ remote = RemoteMCP(rec, client)
180
+ try:
181
+ return remote.call(method, params or {}, timeout, raw=raw)
182
+ finally:
183
+ remote.close()
184
  try:
185
  r = _client.post(_url(rec, "/mcp"), timeout=httpx.Timeout(timeout, connect=6),
186
  headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"},
 
205
  return doc.get("result")
206
 
207
 
208
+ class RemoteMCP:
209
+ """A Streamable HTTP MCP session, isolated along with the visitor's cookies.
210
+
211
+ No credentials from the explorer are forwarded. Expiry never replays an action:
212
+ callers must explicitly open a new episode after a 410 response.
213
+ """
214
+ def __init__(self, rec: dict, client: httpx.Client):
215
+ self.url = _url(rec, "/mcp")
216
+ self.client = client
217
+ self.session = None
218
+ self.version = None
219
+ self.initialized = False
220
+ self.expired = False
221
+
222
+ def headers(self):
223
+ headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}
224
+ if self.session:
225
+ headers["Mcp-Session-Id"] = self.session
226
+ if self.version:
227
+ headers["MCP-Protocol-Version"] = self.version
228
+ return headers
229
+
230
+ def request(self, method, params, timeout, *, notification=False):
231
+ rid = secrets.randbelow(10**9)
232
+ message = {"jsonrpc": "2.0", "method": method, "params": params}
233
+ if not notification:
234
+ message["id"] = rid
235
+ try:
236
+ r = self.client.post(self.url, headers=self.headers(), json=message, timeout=httpx.Timeout(timeout, connect=6))
237
+ except httpx.TimeoutException:
238
+ raise SpaceError("the MCP server timed out; the action was not retried", 504) from None
239
+ except httpx.HTTPError as e:
240
+ raise SpaceError(f"couldn't reach the MCP server: {type(e).__name__}", 502) from None
241
+ if self.session and r.status_code == 404:
242
+ self.expired = True
243
+ raise SpaceError("the MCP session has expired: start a new session", 410)
244
+ if notification and r.status_code in (200, 202, 204):
245
+ return {}
246
+ if r.status_code != 200:
247
+ raise SpaceError(f"the Space's /mcp answered HTTP {r.status_code}", 502)
248
+ try:
249
+ if "text/event-stream" in r.headers.get("content-type", ""):
250
+ # Notifications can precede the response; SSE data may span lines.
251
+ docs = [json.loads("\n".join(line[5:].lstrip() for line in event.splitlines() if line.startswith("data:")))
252
+ for event in r.text.replace("\r\n", "\n").split("\n\n") if any(line.startswith("data:") for line in event.splitlines())]
253
+ doc = next((d for d in docs if isinstance(d, dict) and d.get("id") == rid), None)
254
+ else:
255
+ doc = r.json()
256
+ except ValueError:
257
+ raise SpaceError("the MCP server returned invalid JSON", 502) from None
258
+ if not isinstance(doc, dict) or doc.get("jsonrpc") != "2.0" or doc.get("id") != rid:
259
+ raise SpaceError("the MCP server returned no matching JSON-RPC response", 502)
260
+ if method == "initialize" and not doc.get("error"):
261
+ sid = r.headers.get("mcp-session-id")
262
+ if sid and (len(sid) > 2048 or not all(0x21 <= ord(c) <= 0x7e for c in sid)):
263
+ raise SpaceError("the MCP server returned an invalid session ID", 502)
264
+ self.session = sid
265
+ return doc
266
+
267
+ def call(self, method, params, timeout, *, raw=False):
268
+ if self.expired:
269
+ raise SpaceError("the MCP session has expired: start a new session", 410)
270
+ if not self.initialized:
271
+ init = self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
272
+ "clientInfo": {"name": "hf-rl-explorer", "version": "0.1.0"}}, timeout)
273
+ error = init.get("error") or {}
274
+ if not isinstance(error, dict):
275
+ raise SpaceError("the MCP server returned an invalid initialization error", 502)
276
+ if error.get("code") != -32601: # compatibility with simple JSON-RPC tool servers
277
+ result = init.get("result") or {}
278
+ if not isinstance(result, dict):
279
+ raise SpaceError("the MCP server returned an invalid initialization result", 502)
280
+ self.version = result.get("protocolVersion")
281
+ if error or self.version not in {"2025-03-26", "2025-06-18", "2025-11-25"}:
282
+ raise SpaceError("the MCP server could not negotiate a supported protocol", 502)
283
+ self.request("notifications/initialized", {}, timeout, notification=True)
284
+ self.initialized = True
285
+ doc = self.request(method, params, timeout)
286
+ if raw:
287
+ return doc
288
+ if doc.get("error"):
289
+ error = doc["error"]
290
+ raise SpaceError(str(error.get("message", "MCP error") if isinstance(error, dict) else error)[:500], 422)
291
+ return doc.get("result")
292
+
293
+ def close(self):
294
+ if self.session and not self.expired:
295
+ try:
296
+ self.client.delete(self.url, headers=self.headers(), timeout=3)
297
+ except Exception: # best-effort cleanup of an untrusted remote server
298
+ pass
299
+ self.expired = True
300
+
301
+
302
  def _tools(rec: dict) -> tuple[list[dict] | None, str | None, dict]:
303
  """Its MCP tools (or why there are none), and how /mcp answered: a JSON-RPC 2.0 reply, as `openenv validate`
304
  asks of it, or the failure."""
 
324
  own plumbing (geoguesser's /geoguesser/task/{i} returns the answer's coordinates). GET and POST only."""
325
  if openenv or not isinstance(api, dict) or not isinstance(api.get("paths"), dict):
326
  return []
 
 
327
  def resolve(x: Any, depth: int = 0) -> Any:
328
  if not isinstance(x, (dict, list)):
329
  return x
 
331
  return {} if isinstance(x, dict) else []
332
  if isinstance(x, dict):
333
  if "$ref" in x and isinstance(x["$ref"], str):
334
+ target = api
335
+ if not x["$ref"].startswith("#/"):
336
+ return {}
337
+ for key in x["$ref"][2:].split("/"):
338
+ target = target.get(key.replace("~1", "/").replace("~0", "~"), {}) if isinstance(target, dict) else {}
339
+ return resolve(target, depth + 1)
340
  return {k: resolve(v, depth + 1) for k, v in x.items() if k != "$defs"}
341
  if isinstance(x, list):
342
  return [resolve(v, depth + 1) for v in x[:50]]
 
348
  if (not isinstance(ops, dict) or not isinstance(path, str) or not re.match(r"^/(?!/)[\w\-.~{}/]{0,199}$", path)
349
  or ".." in path):
350
  continue
351
+ # A generic Gymnasium server's reset/step are ordinary HTTP routes, not
352
+ # evidence that it implements OpenEnv's WebSocket envelope protocol.
353
+ if path in STANDARD_ROUTES - {"/reset", "/step", "/state"} or path.startswith(("/web", "/login", "/logout", "/docs", "/redoc", "/static", "/gradio", "/assets")):
354
  continue
355
  if openenv and path in ("/healthz",):
356
  continue
 
360
  continue
361
  body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema")
362
  schema = resolve(body) if body else None
363
+ parameters = {}
364
+ for p in [*(ops.get("parameters") or []), *(op.get("parameters") or [])]:
365
+ p = resolve(p)
366
+ if isinstance(p, dict) and p.get("in") == "query" and isinstance(p.get("name"), str):
367
+ parameters[p["name"]] = p
368
+ query = {"type": "object", "properties": {k: {**(p.get("schema") or {}), "description": p.get("description") or "Query parameter"}
369
+ for k, p in parameters.items()},
370
+ "required": [k for k, p in parameters.items() if p.get("required")], "additionalProperties": False}
371
  out.append({"method": method.upper(), "path": path, "summary": str(op.get("summary") or op.get("description") or "")[:200],
372
  "params": re.findall(r"\{([A-Za-z_][\w-]{0,40})\}", path),
373
+ "schema": schema if isinstance(schema, dict) else None,
374
+ "body_required": bool((op.get("requestBody") or {}).get("required")), "query_schema": query})
375
  if len(out) >= 40:
376
  break
377
  return out
378
 
379
 
380
+ def route_url(info: dict, method: str, path: str, params: dict[str, Any], query: dict[str, Any] | None = None) -> str:
381
  """A route the server published, its path parameters filled in safely; anything else is refused."""
382
  route = next((r for r in info.get("api") or [] if r["method"] == method and r["path"] == path), None)
383
  if route is None:
 
385
  filled = path
386
  for name in route["params"]:
387
  v = str((params or {}).get(name, ""))
388
+ if not re.fullmatch(r"[\w.\-~]{1,200}", v) or v in (".", ".."):
389
  raise SpaceError(f"{name}: letters, digits, . - _ ~ only", 400)
390
  filled = filled.replace("{" + name + "}", v)
391
+ query = query or {}
392
+ schema = route.get("query_schema") or {}
393
+ if set(query) - set(schema.get("properties") or {}):
394
+ raise SpaceError("only this route's published query parameters are allowed", 400)
395
+ for key in schema.get("required") or []:
396
+ if key not in query:
397
+ raise SpaceError(f"query parameter {key} is required", 400)
398
+ pairs = []
399
+ for key, value in query.items():
400
+ for v in value if isinstance(value, list) else [value]:
401
+ if v is None:
402
+ continue
403
+ if not isinstance(v, (str, int, float, bool)):
404
+ raise SpaceError(f"query parameter {key} must be a scalar or array of scalars", 400)
405
+ pairs.append((key, str(v).lower() if isinstance(v, bool) else str(v)))
406
+ encoded = urlencode(pairs)
407
+ if len(encoded) > 8000:
408
+ raise SpaceError("query parameters are too large", 400)
409
+ if encoded:
410
+ filled += "?" + encoded
411
  return filled
412
 
413
 
 
415
  """Everything a running server says about itself, in one record. Asleep or broken: just the stage."""
416
  rec = record(spec, fresh=fresh)
417
  if rec["stage"] != UP or not rec["host"]:
418
+ from .envs.capabilities import space_support
419
+ info = {"running": False, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
420
  "last_seen": last_seen(rec["id"])}
421
+ from . import space_checks
422
+ space_checks.observe(spec, info)
423
+ return {**info, "support": space_support(info)}
424
 
425
  def fetch():
426
  with ThreadPoolExecutor(6) as pool:
 
433
  api, meta, schema, health, envs, ui = (f.result() for f in (f_api, f_meta, f_schema, f_health, f_envs, f_ui))
434
  routes = sorted((api or {}).get("paths", {}).keys()) if isinstance(api, dict) else []
435
  has = set(routes)
436
+ openenv = isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state"))
437
+ tools, tools_error, rpc = _tools({**rec, "mcp_standard": not openenv}) if ("/mcp" in has or openenv) else (None, None, None)
438
  env_names = [e for e in envs if isinstance(e, str) and re.match(r"^[\w.-]{1,80}$", e)] if isinstance(envs, list) else []
439
+ from . import space_tasks
440
+ task_api = space_tasks.discover(rec, has, env_names)
 
 
 
 
 
 
 
 
 
 
 
 
441
  names = {t["name"] for t in tools or []}
442
  harbor = {"run_rollout", "capabilities"} <= names or "harbor_env" in env_names
443
  caps = _harbor_caps(rec) if "capabilities" in names and harbor else None
 
445
  version = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None
446
  info = {
447
  "running": True, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"],
448
+ "ui": ui, "openenv": openenv,
449
  "metadata": meta if isinstance(meta, dict) else None, "health": health if isinstance(health, dict) else None,
450
  "schema": schema if isinstance(schema, dict) else None,
451
+ "step_api": openenv and "/reset" in has and "/step" in has, "docs": "/openapi.json" in has or api is not None,
452
  "mcp": tools, "mcp_error": None if tools else tools_error,
453
+ "task_api": task_api,
454
  "harbor": harbor, "harbor_caps": caps,
455
  "rewards": bool(isinstance(obs, dict) and "reward" in (obs.get("properties") or {})),
456
  "graders": sorted(n for n in names if re.search(r"(grade|score|submit|verify|reward|judge)", n, re.I)),
457
  "routes": [r for r in routes if r not in STANDARD_ROUTES][:40],
458
+ "api": _api_routes(api, openenv),
459
  # which framework the server speaks, from what it serves
460
+ "framework": "harbor" if harbor else "openenv" if openenv else
461
  "nemo-gym" if {"/seed_session", "/verify"} <= has else
462
+ "ors" if {"/create_session", "/{env_name}/call"} <= has else
463
+ "gymnasium" if {"/reset", "/step"} <= has else "mcp" if tools else "api" if api else None,
464
  "openapi_version": version if isinstance(version, str) else None,
465
+ "endpoint_methods": {p: {m: {} for m in methods if m in ("get", "post")} for p, methods in (api or {}).get("paths", {}).items()
466
+ if p in ("/reset", "/step", "/state", "/mcp") and isinstance(methods, dict)} if isinstance(api, dict) else {},
467
  # OpenEnv's two modes: simulation serves /reset, /step and /state; production serves only MCP and /ws
468
+ "mode": ("simulation" if "/reset" in has else "production") if openenv else None,
469
  "conformance": conformance({"openapi": api, "health": health, "metadata": meta, "schema": schema, "mcp": rpc,
470
  "mcp_tried": "/mcp" in has or openenv, "paths": routes}),
471
  "checked": time.time(),
472
  }
473
  if api is not None or isinstance(schema, dict) or isinstance(meta, dict) or tools: # it answered: keep it
474
  remember(rec["id"], info)
475
+ from .envs.capabilities import space_support
476
+ info["support"] = space_support(info)
477
+ from . import space_checks
478
+ space_checks.observe(spec, info)
479
  return info
480
 
481
  if fresh:
 
693
  return v, gone
694
 
695
 
696
+ def _task_api(spec: str, env: str = "") -> tuple[dict, str]:
697
  info = probe(spec)
698
  if not info.get("running"):
699
  raise SpaceError("this Space isn't running: wake it first", 409)
700
  if not info.get("task_api"):
701
  raise SpaceError("this server has no Task API", 404)
702
+ api = info["task_api"]
703
+ if env and env not in [e["env"] for e in api.get("environments", [api])]:
704
+ raise SpaceError("this environment is not in the Space's Task API", 400)
705
+ return record(spec), env or api["env"]
706
 
707
 
708
+ def tasks(spec: str, split: str, start: int, stop: int, env: str = "") -> dict[str, Any]:
709
+ rec, env = _task_api(spec, env)
710
  start, stop = max(0, int(start)), max(0, int(stop))
711
  stop = min(stop, start + 50)
712
  try:
 
720
  return {"tasks": rows, "start": start, "stop": stop, "withheld": sorted(set(gone))}
721
 
722
 
723
+ def task(spec: str, split: str, index: int, env: str = "") -> dict[str, Any]:
724
+ rec, env = _task_api(spec, env)
725
  try:
726
  r = _client.post(_url(rec, f"/{env}/task"), json={"split": split, "index": int(index)}, timeout=20)
727
  except httpx.HTTPError as e:
 
748
  self.created = self.used = time.time()
749
  self.ws = None
750
  self.ws_failed = False
751
+ self.broken = False # a dropped episode may only be recovered by an explicit reset
752
  self.http: httpx.Client | None = None # the session's own cookie jar for the server's routes (NeMo Gym keeps state in a cookie)
753
+ self.remote_mcp: RemoteMCP | None = None
754
+ self.standard_mcp = False
755
  self.lock = threading.Lock()
756
  self.steps = 0
757
  self.rpc = 0
758
 
759
  def close(self) -> None:
760
+ if self.remote_mcp is not None:
761
+ self.remote_mcp.close()
762
+ self.remote_mcp = None
763
  if self.http is not None:
764
  try:
765
  self.http.close()
 
809
  if len(_sessions) >= TOTAL:
810
  raise SpaceError("too many live sessions on this explorer right now: try again in a few minutes", 429)
811
  s = Session(catalog.check_spec(spec), owner)
812
+ s.ws_failed = not bool(info.get("openenv"))
813
+ s.standard_mcp = not bool(info.get("openenv"))
814
  _sessions[s.id] = s
815
  return {"session": s.id}
816
 
 
846
 
847
 
848
  def _ws_send(s: Session, msg: dict, timeout: float) -> dict:
849
+ if s.broken and msg.get("type") != "reset":
850
+ raise SpaceError("the episode was disconnected: reset to start a new episode", 410)
851
+ if msg.get("type") == "reset":
852
+ s.broken = False
853
  if s.ws is None:
854
  _open(s)
855
  try:
 
857
  reply = json.loads(s.ws.recv(timeout=timeout))
858
  except TimeoutError:
859
  s.close()
860
+ s.broken = True
861
  raise SpaceError(f"the Space didn't answer within {int(timeout)} s; the session was closed", 504) from None
862
  except Exception as e: # noqa: BLE001 - the socket dropped: the episode is gone
863
  s.close()
864
+ s.broken = True
865
  raise SpaceError(f"the session dropped ({type(e).__name__}): start a new episode", 502) from None
866
  if not isinstance(reply, dict):
867
  raise SpaceError("the Space answered something unexpected", 502)
 
872
 
873
 
874
  def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any:
875
+ """Use OpenEnv's episode socket, standard HTTP MCP, or legacy OpenEnv HTTP fallback."""
876
  if not s.ws_failed:
877
  try:
878
  s.rpc += 1
 
888
  err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])}
889
  raise SpaceError(str(err.get("message") or "error")[:600], 422)
890
  return data.get("result") if isinstance(data, dict) else data
891
+ if s.standard_mcp:
892
+ if s.http is None:
893
+ s.http = SpaceClient(timeout=httpx.Timeout(120, connect=8), follow_redirects=False)
894
+ if s.remote_mcp is None:
895
+ s.remote_mcp = RemoteMCP(record(s.spec), s.http)
896
+ return s.remote_mcp.call(method, params, timeout)
897
  return mcp_call(record(s.spec), method, params, timeout=timeout)
898
 
899
 
 
903
  if method not in ("GET", "POST"):
904
  raise SpaceError("GET or POST only", 400)
905
  info = probe(s.spec)
906
+ path = route_url(info, method, str(data.get("path") or ""), data.get("params") if isinstance(data.get("params"), dict) else {},
907
+ data.get("query") if isinstance(data.get("query"), dict) else {})
908
  rec = record(s.spec)
909
  if s.http is None:
910
+ s.http = SpaceClient(base_url=rec["host"], timeout=httpx.Timeout(120, connect=8), follow_redirects=False,
911
  headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"})
912
  body = data.get("body")
913
  try:
914
+ r = s.http.request(method, _url(rec, path), json=body if method == "POST" else None)
915
  except httpx.TimeoutException:
916
  raise SpaceError("the Space didn't answer within 120 s", 504) from None
917
  except httpx.HTTPError as e:
 
972
  s.steps += 1
973
  else:
974
  raise SpaceError(f"unknown operation {op!r}", 400)
975
+ return {"result": out, "ms": int((time.time() - t0) * 1000), "steps": s.steps,
976
+ "stateful": op == "http" or s.standard_mcp or not s.ws_failed}
977
 
978
 
979
  def live_sessions() -> int:
app/vendor/nemo_gym/LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
app/vendor/nemo_gym/NOTICE ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ NeMo Gym verification_utils.py from NVIDIA-NeMo/Gym
2
+ Commit: 3ef478df1ee163134d32a3f291f0a9e5981d0e52
3
+ Path: resources_servers/single_step_tool_use_with_argument_comparison/common/verification_utils.py
4
+ SHA256: 41d82a49130adbf69fa9fca86c5f3eed28992ea6909e9dc05131b2830c0a1f89
5
+ Unmodified. Copyright NVIDIA CORPORATION & AFFILIATES. Apache-2.0.
app/vendor/nemo_gym/verification_utils.py ADDED
@@ -0,0 +1,398 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ import json
16
+ from collections import Counter
17
+ from enum import StrEnum
18
+ from json import JSONDecodeError
19
+ from typing import Annotated, Any, Literal, Optional, TypeAlias, Union
20
+
21
+ from pydantic import BaseModel, Field
22
+
23
+
24
+ class MessageAction(BaseModel):
25
+ type: Literal["message"]
26
+ content: str
27
+
28
+
29
+ class FunctionCallAction(BaseModel):
30
+ type: Literal["function_call"]
31
+ name: str
32
+ arguments: str
33
+
34
+
35
+ class FunctionCallBatchAction(BaseModel):
36
+ type: Literal["function_call_batch"]
37
+ calls: list[FunctionCallAction] = Field(min_length=1)
38
+
39
+
40
+ # Actions are canonical on both sides of a comparison: dataset rows deserialize into them, and model
41
+ # responses are normalized into them by `response_utils.extract_action`. The alias keeps the name
42
+ # that dataset validation tooling imports.
43
+ ExpectedAction: TypeAlias = Annotated[
44
+ Union[MessageAction, FunctionCallAction, FunctionCallBatchAction],
45
+ Field(discriminator="type"),
46
+ ]
47
+
48
+
49
+ def get_tool_calls(action: ExpectedAction) -> list[FunctionCallAction]:
50
+ """Flatten an action into the tool calls it represents, so single and parallel calls share a path."""
51
+ if isinstance(action, FunctionCallBatchAction):
52
+ return list(action.calls)
53
+
54
+ if isinstance(action, FunctionCallAction):
55
+ return [action]
56
+
57
+ return []
58
+
59
+
60
+ class StepRewardCategory(StrEnum):
61
+ NO_ACTION_FOUND = "No tool call or chat message was found in the response"
62
+ NO_EXPECTED_TOOL_CALL = "No tool call was found when one was expected"
63
+ EXPECTED_CHAT_MESSAGE_FOUND = "A chat message was found as expected"
64
+ NO_EXPECTED_CHAT_MESSAGE = "A tool call was executed when a chat message was expected"
65
+ UNEXPECTED_TOOL = "The tool in a tool call is not the expected tool"
66
+ ARGUMENTS_DECODE_ERROR = "An error occurred when decoding the arguments string in a tool call as a JSON object"
67
+ ARGUMENT_VALUE_TYPE_DIFFERENT = "The type of an argument value in a tool call is different than the expected type"
68
+ ARGUMENT_OBJECT_KEYS_DIFFERENT = (
69
+ "The keys in an object in an argument value in a tool call are different than the keys in the expected object"
70
+ )
71
+ ARGUMENT_LIST_LENGTH_DIFFERENT = (
72
+ "A list in an argument value in a tool call has a different length than the expected list"
73
+ )
74
+ ARGUMENT_VALUE_DIFFERENT = "An argument value in a tool call is different than the expected value"
75
+ EXPECTED_TOOL_CALL = "A tool call that matches the expected tool call was found"
76
+ FUNCTION_CALL_BATCH_LENGTH_DIFFERENT = "The number of tool calls in a batch is different than expected"
77
+ EXPECTED_TOOL_CALL_BATCH = "A tool-call batch that matches the expected tool calls was found"
78
+
79
+
80
+ class ParallelToolCallRewardMode(StrEnum):
81
+ """How an admissible parallel tool-call response converts its matched-call count into a reward."""
82
+
83
+ BINARY_STRICT = "binary_strict"
84
+ FRACTIONAL = "fractional"
85
+ F1 = "f1"
86
+
87
+
88
+ class ActionComparisonResult(BaseModel):
89
+ reward: float
90
+ category: StepRewardCategory
91
+
92
+
93
+ class ToolCallComparatorConfig(BaseModel):
94
+ word_count_similarity_threshold: float
95
+ floating_point_comparison_threshold: float = 1e-6
96
+
97
+ # Master switch: does the NUMBER of tool calls the model made affect its reward?
98
+ #
99
+ # False (default) turns parallel tool-call rewarding off entirely. The question asked of a
100
+ # response is only "did it make the expected call(s)?", so surplus calls are never penalized.
101
+ # This reproduces the behaviour that predates parallel tool-call support, which is why it is the
102
+ # default: existing datasets score exactly as they did before. It is also the honest default,
103
+ # because chat templates do not render differently for `parallel_tool_calls` (the Nemotron
104
+ # template never references the flag), so a model is never told how many calls it may make.
105
+ #
106
+ # True turns it on: the call count becomes part of the verdict, and the three settings below
107
+ # decide which counts are admissible and how partial matches are scored. Turn this on for
108
+ # datasets that use `expected_action.type: function_call_batch`.
109
+ parallel_tool_call_rewarding: bool = False
110
+
111
+ # The three settings below are only consulted when `parallel_tool_call_rewarding` is True.
112
+
113
+ # Cardinality gate: whether a response that makes fewer / more tool calls than expected is
114
+ # admissible at all. Both default to False, so the call count must match exactly.
115
+ allow_subset: bool = False
116
+ allow_superset: bool = False
117
+
118
+ # Scoring for responses that clear the gate. See `ActionComparator.compare_tool_calls`.
119
+ parallel_tool_call_reward_mode: ParallelToolCallRewardMode = ParallelToolCallRewardMode.BINARY_STRICT
120
+
121
+
122
+ def find_maximum_matching(candidates: list[list[int]]) -> dict[int, int]:
123
+ """Maximum bipartite matching between expected and actual tool calls (Kuhn's algorithm).
124
+
125
+ `candidates[expected_index]` lists the actual-call indices that expected call could match, and the
126
+ returned mapping goes from actual index to the expected index it was matched with. Greedy pairing
127
+ is not enough here: argument matching is a fuzzy relation, so one actual call can satisfy several
128
+ expected calls and an early arbitrary pairing can strand a later expected call that had no
129
+ alternative. Augmenting paths undo those pairings and recover the true maximum.
130
+ """
131
+ matching: dict[int, int] = {}
132
+
133
+ # Matching the most constrained expected calls first keeps the number of augmenting paths small.
134
+ for expected_index in sorted(range(len(candidates)), key=lambda index: len(candidates[index])):
135
+ _augment_matching(expected_index, candidates, matching, set())
136
+
137
+ return matching
138
+
139
+
140
+ def _augment_matching(
141
+ expected_index: int,
142
+ candidates: list[list[int]],
143
+ matching: dict[int, int],
144
+ visited_actual_indices: set[int],
145
+ ) -> bool:
146
+ for actual_index in candidates[expected_index]:
147
+ if actual_index in visited_actual_indices:
148
+ continue
149
+
150
+ visited_actual_indices.add(actual_index)
151
+ is_unmatched = actual_index not in matching
152
+ if is_unmatched or _augment_matching(matching[actual_index], candidates, matching, visited_actual_indices):
153
+ matching[actual_index] = expected_index
154
+ return True
155
+
156
+ return False
157
+
158
+
159
+ class ActionComparator(BaseModel):
160
+ config: ToolCallComparatorConfig
161
+
162
+ def compare_action(self, expected_action: ExpectedAction, actual_action: ExpectedAction) -> ActionComparisonResult:
163
+ match expected_action:
164
+ case MessageAction():
165
+ # Currently, any chat message is assigned a reward of one.
166
+ if isinstance(actual_action, MessageAction):
167
+ return ActionComparisonResult(reward=1.0, category=StepRewardCategory.EXPECTED_CHAT_MESSAGE_FOUND)
168
+
169
+ return ActionComparisonResult(reward=0.0, category=StepRewardCategory.NO_EXPECTED_CHAT_MESSAGE)
170
+
171
+ case FunctionCallAction() | FunctionCallBatchAction():
172
+ if isinstance(actual_action, MessageAction):
173
+ return ActionComparisonResult(reward=0.0, category=StepRewardCategory.NO_EXPECTED_TOOL_CALL)
174
+
175
+ return self.compare_tool_calls(get_tool_calls(expected_action), get_tool_calls(actual_action))
176
+
177
+ case _:
178
+ raise NotImplementedError(f"Unsupported expected action: {expected_action!r}")
179
+
180
+ def compare_tool_calls(
181
+ self, expected_calls: list[FunctionCallAction], actual_calls: list[FunctionCallAction]
182
+ ) -> ActionComparisonResult:
183
+ """Score a set of tool calls against the expected set, ignoring the order they were emitted in.
184
+
185
+ `parallel_tool_call_rewarding` is the master switch. While it is False (the default) the number
186
+ of calls is simply not part of the verdict: the response is asked only whether it made the
187
+ expected call(s), and surplus calls cost nothing. That reproduces the behaviour that predates
188
+ parallel tool-call support.
189
+
190
+ Turning it on makes the call count matter, in two independent stages. The cardinality gate
191
+ (`allow_subset` / `allow_superset`) decides whether a response that under- or over-calls is
192
+ admissible at all; anything it rejects scores zero. `parallel_tool_call_reward_mode` then decides
193
+ how much credit an admissible response earns:
194
+
195
+ - `binary_strict` — 1.0 only if every required call matched, else 0.0.
196
+ - `fractional` — the matched fraction of the required calls. Surplus calls permitted by
197
+ `allow_superset` are free, so this rewards recall but not precision.
198
+ - `f1` — the harmonic mean of precision and recall, `2 * matched / (expected + actual)`. Missing
199
+ and surplus calls are penalized symmetrically, so a response only reaches 1.0 by matching the
200
+ expected calls exactly.
201
+ """
202
+ expected_count = len(expected_calls)
203
+ actual_count = len(actual_calls)
204
+
205
+ if expected_count == 1 and actual_count == 1:
206
+ # Preserve the single-call categories that predate parallel tool-call support.
207
+ return self.compare_tool_call(expected_calls[0], actual_calls[0])
208
+
209
+ if not self.is_call_count_admissible(expected_count, actual_count):
210
+ return ActionComparisonResult(reward=0.0, category=StepRewardCategory.FUNCTION_CALL_BATCH_LENGTH_DIFFERENT)
211
+
212
+ candidates, failure_categories = self.build_match_candidates(expected_calls, actual_calls)
213
+ matching = find_maximum_matching(candidates)
214
+ reward = self.score_matched_calls(len(matching), expected_count, actual_count)
215
+ if reward == 1.0:
216
+ # A single expected call keeps the category it had before parallel support existed.
217
+ category = (
218
+ StepRewardCategory.EXPECTED_TOOL_CALL
219
+ if expected_count == 1
220
+ else StepRewardCategory.EXPECTED_TOOL_CALL_BATCH
221
+ )
222
+ return ActionComparisonResult(reward=reward, category=category)
223
+
224
+ category = self.resolve_failure_category(
225
+ matched_expected_indices=set(matching.values()),
226
+ failure_categories=failure_categories,
227
+ expected_count=expected_count,
228
+ actual_count=actual_count,
229
+ )
230
+ return ActionComparisonResult(reward=reward, category=category)
231
+
232
+ def is_call_count_admissible(self, expected_count: int, actual_count: int) -> bool:
233
+ if actual_count == expected_count:
234
+ return True
235
+
236
+ # With parallel tool-call rewarding off, the call count is not part of the verdict at all.
237
+ if not self.config.parallel_tool_call_rewarding:
238
+ return True
239
+
240
+ if actual_count < expected_count:
241
+ return self.config.allow_subset
242
+
243
+ return self.config.allow_superset
244
+
245
+ def required_match_count(self, expected_count: int, actual_count: int) -> int:
246
+ """How many expected calls must match for full credit under `binary_strict` and `fractional`."""
247
+ if self.config.allow_subset:
248
+ return min(expected_count, actual_count)
249
+
250
+ return expected_count
251
+
252
+ def score_matched_calls(self, matched_count: int, expected_count: int, actual_count: int) -> float:
253
+ if not self.config.parallel_tool_call_rewarding:
254
+ # The call count is not part of the verdict, so neither the gate nor the reward mode
255
+ # applies: full credit exactly when every expected call was matched.
256
+ return 1.0 if matched_count == expected_count else 0.0
257
+
258
+ if self.config.parallel_tool_call_reward_mode == ParallelToolCallRewardMode.F1:
259
+ total_count = expected_count + actual_count
260
+ return 2 * matched_count / total_count if total_count else 0.0
261
+
262
+ required_count = self.required_match_count(expected_count, actual_count)
263
+ if self.config.parallel_tool_call_reward_mode == ParallelToolCallRewardMode.FRACTIONAL:
264
+ return matched_count / required_count if required_count else 0.0
265
+
266
+ return 1.0 if matched_count == required_count else 0.0
267
+
268
+ def build_match_candidates(
269
+ self, expected_calls: list[FunctionCallAction], actual_calls: list[FunctionCallAction]
270
+ ) -> tuple[list[list[int]], list[StepRewardCategory]]:
271
+ """Pair every expected call with the actual calls it matches, keeping why the others missed."""
272
+ candidates: list[list[int]] = []
273
+ failure_categories: list[StepRewardCategory] = []
274
+
275
+ for expected_call in expected_calls:
276
+ matching_actual_indices: list[int] = []
277
+ failure_category = StepRewardCategory.UNEXPECTED_TOOL
278
+
279
+ for actual_index, actual_call in enumerate(actual_calls):
280
+ result = self.compare_tool_call(expected_call, actual_call)
281
+ if result.reward == 1.0:
282
+ matching_actual_indices.append(actual_index)
283
+
284
+ elif failure_category == StepRewardCategory.UNEXPECTED_TOOL:
285
+ # Keep the first reason that got past the tool name; it explains the closest near miss.
286
+ failure_category = result.category
287
+
288
+ candidates.append(matching_actual_indices)
289
+ failure_categories.append(failure_category)
290
+
291
+ return candidates, failure_categories
292
+
293
+ def resolve_failure_category(
294
+ self,
295
+ matched_expected_indices: set[int],
296
+ failure_categories: list[StepRewardCategory],
297
+ expected_count: int,
298
+ actual_count: int,
299
+ ) -> StepRewardCategory:
300
+ unmatched_expected_indices = [
301
+ expected_index
302
+ for expected_index in range(expected_count)
303
+ if expected_index not in matched_expected_indices
304
+ ]
305
+
306
+ # Either every expected call was matched and the only defect left is surplus calls, or every
307
+ # actual call was consumed and the only defect left is missing calls. Reporting an argument
308
+ # mismatch in those cases would point at a comparison that is not why the reward was docked.
309
+ if not unmatched_expected_indices or len(matched_expected_indices) == actual_count:
310
+ return StepRewardCategory.FUNCTION_CALL_BATCH_LENGTH_DIFFERENT
311
+
312
+ return failure_categories[unmatched_expected_indices[0]]
313
+
314
+ def compare_tool_call(
315
+ self, expected_tool_call: FunctionCallAction, actual_tool_call: FunctionCallAction
316
+ ) -> ActionComparisonResult:
317
+ if expected_tool_call.name != actual_tool_call.name:
318
+ return ActionComparisonResult(reward=0.0, category=StepRewardCategory.UNEXPECTED_TOOL)
319
+
320
+ # It is assumed that the expected arguments string is a string representation of a JSON object.
321
+ expected_arguments = json.loads(expected_tool_call.arguments)
322
+
323
+ try:
324
+ actual_arguments = json.loads(actual_tool_call.arguments)
325
+ except (JSONDecodeError, UnicodeDecodeError):
326
+ return ActionComparisonResult(reward=0.0, category=StepRewardCategory.ARGUMENTS_DECODE_ERROR)
327
+
328
+ arguments_match, category = self.compare_tool_call_arguments(expected_arguments, actual_arguments)
329
+ if arguments_match:
330
+ return ActionComparisonResult(reward=1.0, category=StepRewardCategory.EXPECTED_TOOL_CALL)
331
+
332
+ return ActionComparisonResult(reward=0.0, category=category)
333
+
334
+ def compare_tool_call_arguments(
335
+ self, expected_value: Any, actual_value: Any
336
+ ) -> tuple[bool, Optional[StepRewardCategory]]:
337
+ if not isinstance(actual_value, type(expected_value)):
338
+ return False, StepRewardCategory.ARGUMENT_VALUE_TYPE_DIFFERENT
339
+
340
+ if isinstance(expected_value, dict):
341
+ if set(expected_value.keys()) != set(actual_value.keys()):
342
+ return False, StepRewardCategory.ARGUMENT_OBJECT_KEYS_DIFFERENT
343
+
344
+ for expected_dict_key, expected_dict_value in expected_value.items():
345
+ actual_dict_value = actual_value[expected_dict_key]
346
+ dict_value_match, dict_value_category = self.compare_tool_call_arguments(
347
+ expected_dict_value, actual_dict_value
348
+ )
349
+ if not dict_value_match:
350
+ return dict_value_match, dict_value_category
351
+
352
+ return True, None
353
+
354
+ elif isinstance(expected_value, list):
355
+ if len(expected_value) != len(actual_value):
356
+ return False, StepRewardCategory.ARGUMENT_LIST_LENGTH_DIFFERENT
357
+
358
+ for expected_list_element, actual_list_element in zip(expected_value, actual_value):
359
+ list_element_match, list_element_category = self.compare_tool_call_arguments(
360
+ expected_list_element, actual_list_element
361
+ )
362
+ if not list_element_match:
363
+ return list_element_match, list_element_category
364
+
365
+ return True, None
366
+
367
+ elif isinstance(expected_value, float):
368
+ if abs(actual_value - expected_value) < self.config.floating_point_comparison_threshold:
369
+ return True, None
370
+ else:
371
+ return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
372
+
373
+ elif isinstance(expected_value, str):
374
+ # For now, strings are compared by using whitespace to split them into lower-case
375
+ # words, counting the words, and comparing the word counts using Jaccard similarity.
376
+ expected_word_counts = Counter(expected_value.strip().lower().split())
377
+ actual_word_counts = Counter(actual_value.strip().lower().split())
378
+ expected_word_total = expected_word_counts.total()
379
+ actual_word_total = actual_word_counts.total()
380
+
381
+ if expected_word_total < 2 or actual_word_total < 2:
382
+ if expected_value != actual_value:
383
+ return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
384
+
385
+ else:
386
+ intersection_word_counts = expected_word_counts & actual_word_counts
387
+
388
+ word_count_similarity = intersection_word_counts.total() / (expected_word_total + actual_word_total)
389
+ if word_count_similarity < self.config.word_count_similarity_threshold:
390
+ return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
391
+
392
+ return True, None
393
+
394
+ elif expected_value == actual_value:
395
+ return True, None
396
+
397
+ else:
398
+ return False, StepRewardCategory.ARGUMENT_VALUE_DIFFERENT
app/version.py CHANGED
@@ -26,9 +26,9 @@ def app_version() -> str:
26
  @lru_cache(maxsize=1)
27
  def source_hash() -> str:
28
  h = hashlib.sha256()
29
- for base in (ROOT / "app", ROOT / "web" / "js"):
30
  for p in sorted(base.rglob("*")):
31
- if p.is_file() and p.suffix in (".py", ".js", ".txt", ".json") and "__pycache__" not in p.parts:
32
  h.update(p.relative_to(ROOT).as_posix().encode())
33
  h.update(p.read_bytes())
34
  return h.hexdigest()[:12]
 
26
  @lru_cache(maxsize=1)
27
  def source_hash() -> str:
28
  h = hashlib.sha256()
29
+ for base in (ROOT / "app", ROOT / "web", ROOT / "web-admin"):
30
  for p in sorted(base.rglob("*")):
31
+ if p.is_file() and p.suffix in (".py", ".js", ".txt", ".json", ".css", ".html") and "__pycache__" not in p.parts:
32
  h.update(p.relative_to(ROOT).as_posix().encode())
33
  h.update(p.read_bytes())
34
  return h.hexdigest()[:12]
pyproject.toml CHANGED
@@ -9,6 +9,7 @@ dependencies = [
9
  "httpx>=0.27",
10
  "huggingface_hub>=1.29",
11
  "pyyaml>=6",
 
12
  "cryptography>=44",
13
  "openenv",
14
  "harbor>=0.23",
 
9
  "httpx>=0.27",
10
  "huggingface_hub>=1.29",
11
  "pyyaml>=6",
12
+ "packaging>=24.2",
13
  "cryptography>=44",
14
  "openenv",
15
  "harbor>=0.23",
scripts/deploy_spaces.py CHANGED
@@ -42,8 +42,8 @@ SPACES = {
42
  }
43
  # never uploaded: local data and caches, the READMEs (each Space gets its own as README.md), editor and OS files
44
  IGNORE = [".venv/*", ".local-data/*", "*/__pycache__/*", "__pycache__/*", "*.pyc", ".pytest_cache/*", ".ruff_cache/*",
45
- ".gradio/*", "node_modules/*", ".DS_Store", "*/.DS_Store", "README.md", "SPACE_README.md", "ADMIN_SPACE_README.md",
46
- ".env", "*.env", ".git/*"]
47
 
48
 
49
  SKIP_DIRS = {".venv", ".local-data", "__pycache__", ".pytest_cache", ".ruff_cache", ".gradio", "node_modules", ".git"}
@@ -53,10 +53,10 @@ def files() -> list[str]:
53
  """What goes into the Space: walked without descending into local data, caches or the virtualenv."""
54
  out = []
55
  for root, dirs, names in os.walk(ROOT):
56
- dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
57
  for n in sorted(names):
58
  rel = (Path(root) / n).relative_to(ROOT).as_posix()
59
- if not any(fnmatch.fnmatch(rel, pat) for pat in IGNORE):
60
  out.append(rel)
61
  return out
62
 
 
42
  }
43
  # never uploaded: local data and caches, the READMEs (each Space gets its own as README.md), editor and OS files
44
  IGNORE = [".venv/*", ".local-data/*", "*/__pycache__/*", "__pycache__/*", "*.pyc", ".pytest_cache/*", ".ruff_cache/*",
45
+ ".gradio/*", "node_modules/*", ".DS_Store", "*/.DS_Store", "README.md", "SPACE_README.md", "ADMIN_SPACE_README.md", "EXPLORER_AUDIT.md",
46
+ ".env", ".env.*", "*/.env", "*/.env.*", "*.env", "*.pem", "*.key", ".git/*"]
47
 
48
 
49
  SKIP_DIRS = {".venv", ".local-data", "__pycache__", ".pytest_cache", ".ruff_cache", ".gradio", "node_modules", ".git"}
 
53
  """What goes into the Space: walked without descending into local data, caches or the virtualenv."""
54
  out = []
55
  for root, dirs, names in os.walk(ROOT):
56
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not (Path(root) / d).is_symlink())
57
  for n in sorted(names):
58
  rel = (Path(root) / n).relative_to(ROOT).as_posix()
59
+ if not (Path(root) / n).is_symlink() and not any(fnmatch.fnmatch(rel, pat) for pat in IGNORE):
60
  out.append(rel)
61
  return out
62
 
tests/reward-stats.test.mjs ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { rewardDistribution } from '../web/js/reward-stats.js';
4
+
5
+ test('negative and unbounded rewards remain in the histogram', () => {
6
+ const d = rewardDistribution([-100, -5, 0, 0.5, 1, 500]);
7
+ assert.equal(d.min, -100);
8
+ assert.equal(d.max, 500);
9
+ assert.equal(d.hist.reduce((a, b) => a+b), 6);
10
+ assert.equal(d.hist[0], 1);
11
+ assert.equal(d.hist[9], 1);
12
+ });
13
+ test('empty and normalized rewards keep a useful finite range', () => {
14
+ for (const xs of [[], [0, 0.5, 1], [NaN, Infinity]]) {
15
+ const d = rewardDistribution(xs);
16
+ assert.equal(d.min, 0);
17
+ assert.equal(d.max, 1);
18
+ assert.ok(d.hist.every(Number.isFinite));
19
+ }
20
+ });
tests/schemaform.test.mjs ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { schemaForm, readForm, resolveSchema, validateValue } from '../web/js/schemaform.js';
4
+
5
+ const form = (fields = [], root = null) => ({
6
+ querySelector: () => root,
7
+ querySelectorAll: () => fields.map(([k, t, value]) => ({ dataset: { k, t }, value })),
8
+ });
9
+ const plain = (v) => JSON.parse(JSON.stringify(v));
10
+
11
+ test('optional booleans can be omitted, and explicit false remains false', () => {
12
+ const schema = { type: 'object', properties: { enabled: { type: 'boolean', default: true } } };
13
+ assert.deepEqual(plain(readForm(form([['enabled', 'bool', '']]), schema)), {});
14
+ assert.deepEqual(plain(readForm(form([['enabled', 'bool', 'false']]), schema)), { enabled: false });
15
+ assert.match(schemaForm(schema), /Use server default/);
16
+ });
17
+
18
+ test('integer inputs reject fractions rather than truncating actions', () => {
19
+ const s = { type: 'object', properties: { action: { type: 'integer', minimum: 0, maximum: 3 } }, required: ['action'] };
20
+ for (const value of ['1.5', '4', '-1', 'Infinity', '9007199254740992', '']) {
21
+ assert.throws(() => readForm(form([['action', 'int', value]]), s));
22
+ }
23
+ assert.equal(readForm(form([['action', 'int', '0']]), s).action, 0);
24
+ });
25
+
26
+ test('enum values retain their JSON type and never silently pick the first option', () => {
27
+ const s = { type: 'object', properties: { action: { enum: [0, 1, 'stop', null] } }, required: ['action'] };
28
+ assert.match(schemaForm(s), /Choose a value/);
29
+ assert.equal(readForm(form([['action', 'enum', '0']]), s).action, 0);
30
+ assert.equal(readForm(form([['action', 'enum', 'null']]), s).action, null);
31
+ assert.throws(() => readForm(form([['action', 'enum', '"bad"']]), s));
32
+ });
33
+
34
+ test('local references retain defaults and referenced discriminators', () => {
35
+ const s = { type: 'object', $defs: { Mode: { const: 'move' }, Count: { type: 'integer', default: 7 } },
36
+ properties: { type: { $ref: '#/$defs/Mode' }, count: { $ref: '#/$defs/Count' } } };
37
+ assert.match(schemaForm(s), /value="7"/);
38
+ assert.equal(readForm(form(), s).type, 'move');
39
+ });
40
+
41
+ test('recursive and missing references are bounded', () => {
42
+ const s = { $ref: '#/$defs/Loop', $defs: { Loop: { $ref: '#/$defs/Loop' } } };
43
+ assert.doesNotThrow(() => schemaForm(s));
44
+ assert.deepEqual(resolveSchema({ $ref: '#/$defs/missing' }, s), {});
45
+ });
46
+
47
+ test('root action unions use JSON without losing nested arguments', () => {
48
+ const s = { oneOf: [
49
+ { type: 'object', properties: { type: { const: 'move' }, position: { type: 'array', items: { type: 'number' }, minItems: 2 } }, required: ['type', 'position'] },
50
+ { type: 'object', properties: { type: { const: 'stop' } }, required: ['type'] },
51
+ ] };
52
+ assert.match(schemaForm(s), /data-root-json/);
53
+ assert.deepEqual(readForm(form([], { value: '{"type":"move","position":[1,2]}' }), s), { type: 'move', position: [1, 2] });
54
+ assert.throws(() => readForm(form([], { value: '{"type":"move"}' }), s));
55
+ });
56
+
57
+ test('open objects have a JSON editor rather than claiming no inputs', () => {
58
+ assert.match(schemaForm({ type: 'object' }), /data-root-json/);
59
+ assert.match(schemaForm({ type: 'object', additionalProperties: false }), /No inputs/);
60
+ });
61
+
62
+ test('all interpolated upstream schema values are escaped', () => {
63
+ const payload = '\"><img src=https://evil.test/>';
64
+ const s = { type: 'object', properties: { [payload]: { type: 'number', default: payload, minimum: payload } } };
65
+ const html = schemaForm(s, { prefix: payload });
66
+ assert.ok(!html.includes('<img'));
67
+ assert.ok(!html.includes('min="' + payload));
68
+ });
69
+
70
+ test('sanitized property names cannot create duplicate input ids', () => {
71
+ const html = schemaForm({ properties: { 'a.b': { type: 'string' }, 'a/b': { type: 'string' } } });
72
+ const ids = [...html.matchAll(/\bid="([^"]+)"/g)].map((x) => x[1]);
73
+ assert.equal(ids.length, new Set(ids).size);
74
+ });
75
+
76
+ test('prototype-like field names remain ordinary JSON properties', () => {
77
+ const schema = JSON.parse('{"type":"object","properties":{"__proto__":{"type":"object"}}}');
78
+ const result = readForm(form([['__proto__', 'json', '{"polluted":true}']]), schema);
79
+ assert.equal(Object.getPrototypeOf(result), null);
80
+ assert.equal({}.polluted, undefined);
81
+ assert.equal(JSON.parse(JSON.stringify(result)).__proto__.polluted, true);
82
+ });
83
+
84
+ test('nested required, size and additional-properties constraints report actionable errors', () => {
85
+ const s = { type: 'object', properties: { args: { type: 'object', properties: { text: { type: 'string', minLength: 2 } }, required: ['text'], additionalProperties: false } } };
86
+ assert.throws(() => validateValue({ args: {} }, s), /args.text is required/);
87
+ assert.throws(() => validateValue({ args: { text: '' } }, s), /at least 2/);
88
+ assert.throws(() => validateValue({ args: { text: 'ok', answer: 'secret' } }, s), /not an allowed field/);
89
+ });
tests/space-media.test.mjs ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { spaceMedia } from '../web/js/space-media.js';
4
+ import { taskContent } from '../web/js/space-task-view.js';
5
+
6
+ test('lazy Space media resolves against the serving Space without mutating observations', () => {
7
+ const raw = { observation: { asset_path: '/assets/hash?task_id=a', mime: 'audio/wav' } };
8
+ const out = spaceMedia(raw, 'https://owner-env.hf.space');
9
+ assert.equal(out.observation.media_url, 'https://owner-env.hf.space/assets/hash?task_id=a');
10
+ assert.equal(raw.observation.media_url, undefined);
11
+ });
12
+
13
+ test('media paths cannot change origin or use active URL schemes', () => {
14
+ for (const path of ['//evil.example/assets/a', 'javascript:alert(1)', 'https://evil.example/a', '/other/a'])
15
+ assert.equal(spaceMedia({ asset_path: path, mime: 'audio/wav' }, 'https://owner-env.hf.space').media_url, undefined);
16
+ assert.equal(spaceMedia({ asset_path: '/assets/a', mime: 'audio/wav' }, 'http://localhost').media_url, undefined);
17
+ });
18
+
19
+ test('task inputs are escaped and lazy media remains an available episode', () => {
20
+ const html = taskContent({ prompt: '<img src=x onerror=alert(1)>', task_id: 'a'.repeat(200), media_ready: false });
21
+ assert.ok(!html.includes('<img src=x'));
22
+ assert.ok(html.includes('Media loads when the episode starts'));
23
+ assert.ok(html.includes('Task record'));
24
+ });
tests/test_admin_dashboard.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from contextlib import contextmanager
3
+
4
+ import pytest
5
+ from fastapi.testclient import TestClient
6
+
7
+ from app import admin, admin_app, auth, auto_index, catalog, community, search_api, settings, snapshot, space_checks, store
8
+
9
+ client = TestClient(admin_app.app)
10
+
11
+
12
+ @pytest.fixture
13
+ def data(monkeypatch):
14
+ now = time.time()
15
+ checks = {name: {'id': name, 'stage': 'RUNNING', 'status': status, 'checked_at': at, 'interface': interface,
16
+ 'task_catalog': {'tasks': 50, 'counted_splits': 1, 'complete': True, 'checked_at': now}}
17
+ for name, status, at, interface in [('org/openenv', space_checks.PASS, now, None),
18
+ ('org/stale', space_checks.PASS, now-7200, None), ('org/nemo', 'Checks failed', now, 'nemo-gym')]}
19
+ envs = [{'id':'org/harbor','key':'org/harbor','kind':'dataset','framework':'harbor','indexed':{'tasks':8}}]
20
+ envs += [{'id':name, 'key':'space:'+name, 'kind':'space', 'api_check':rec, 'declared_version':'0.3.0'} for name,rec in checks.items()]
21
+ base = {'user':'alice','dataset':'org/harbor','path':'task','title':'Example','status':'done','reward':1,
22
+ 'visibility':'public','created_at':now,'updated_at':now,'avatar':'https://example.com/avatar.png'}
23
+ runs = [{**base,'id':'harbor','runner':'harbor','reward':-2}, {**base,'id':'mimo','runner':'mimo','domain':'code','visibility':'private'},
24
+ {**base,'id':'nemo','runner':'nemo_gym'}, {**base,'id':'hidden','runner':'harbor'},
25
+ {**base,'id':'restricted','runner':'harbor','restricted':True}, {**base,'id':'ungraded','reward':None},
26
+ {**base,'id':'live','runner':'mimo','status':'running','reward':None}]
27
+ monkeypatch.setattr(auth,'current_user',lambda request:{'name':'alice'})
28
+ monkeypatch.setattr(settings,'is_admin',lambda u:True)
29
+ monkeypatch.setattr(settings,'get',lambda k,d=None: ['hidden'] if k=='hidden_runs' else d)
30
+ monkeypatch.setattr(admin,'_runs',lambda:runs)
31
+ monkeypatch.setattr(admin,'avatar',lambda u:None)
32
+ monkeypatch.setattr(search_api,'environments',lambda **kw:envs)
33
+ monkeypatch.setattr(space_checks,'inventory',lambda:checks)
34
+ monkeypatch.setattr(catalog,'hidden',lambda:set())
35
+ monkeypatch.setattr(auto_index,'status',lambda:{'state':'idle','last_success':now-20,'next_at':now+600})
36
+ monkeypatch.setattr(snapshot,'status',lambda:{'built_at':'2026-10-04T00:00:00+00:00'})
37
+ @contextmanager
38
+ def use():
39
+ yield None,None
40
+ monkeypatch.setattr(snapshot,'use',use)
41
+ monkeypatch.setattr(search_api,'sql_extra',lambda conn,ctx,n,q:{'stats':{'datasets':1,'spaces':2,'tasks':8}})
42
+ return envs,runs,checks
43
+
44
+
45
+ def test_overview_matches_community_eligibility_and_checked_spaces(data):
46
+ d=client.get('/api/admin/overview').json()
47
+ assert d['environments']['spaces']==3
48
+ assert d['environments']['checked_spaces']==2
49
+ assert d['environments']['openenv']==1
50
+ assert d['environments']['public']=={'datasets':1,'spaces':2,'tasks':8}
51
+ assert d['rollouts']['public']==2
52
+ assert d['rollouts']['private']==1
53
+ assert len(d['live'])==1
54
+ assert dict(d['rollouts']['by_runner'])=={'harbor':4,'mimo':2,'nemo-gym':1}
55
+
56
+
57
+ def test_operations_show_expired_checks_without_counting_them_as_ready(data,monkeypatch):
58
+ monkeypatch.setattr(auto_index,'status',lambda:{'state':'indexing','next_at':time.time()-5000})
59
+ monkeypatch.setattr(catalog,'hidden',lambda:{'space:org/nemo'})
60
+ d=admin._operations(data[0])
61
+ assert d['indexer']['stale']
62
+ assert d['checks']['ready']==2
63
+ assert d['checks']['statuses']['Check expired']==1
64
+ assert d['task_catalog']['tasks']==50 # hidden and expired Spaces excluded
65
+ assert d['checks']['versions']==[('0.3.0',1)]
66
+
67
+
68
+ def test_admin_runner_visibility_filters_and_safe_pagination(data):
69
+ d=client.get('/api/admin/rollouts?runner=nemo-gym&visibility=community').json()
70
+ assert d['total']==1 and d['runs'][0]['runner']=='nemo-gym'
71
+ assert d['runs'][0]['community']
72
+ assert d['runners']==['harbor','mimo','nemo-gym']
73
+ assert client.get('/api/admin/rollouts?visibility=community').json()['total']==2
74
+ assert client.get('/api/admin/rollouts?visibility=moderated').json()['total']==1
75
+ assert client.get('/api/admin/rollouts?visibility=private').json()['total']==1
76
+ assert client.get('/api/admin/rollouts?offset=-8&limit=1').json()['runs'][0]['id']=='harbor'
77
+
78
+
79
+ def test_people_do_not_treat_reward_above_one_as_universal_success(data):
80
+ d=client.get('/api/admin/people').json()['people'][0]
81
+ assert 'solved' not in d
82
+ assert d['scored']==5 and d['public']==2
83
+
84
+
85
+ def test_environment_admin_keeps_unverified_candidates_for_review(data):
86
+ rows=client.get('/api/admin/environments').json()['environments']
87
+ assert len(rows)==4
88
+ assert {r['id'] for r in rows if r['ready']}=={'org/harbor','org/openenv','org/nemo'}
89
+
90
+
91
+ def test_public_eligibility_reused_for_admin_and_community(data):
92
+ hidden={'hidden'}
93
+ for r in data[1]:
94
+ assert admin._admin_view(r)['community']==community.is_public(r,hidden)
tests/test_audit_regressions.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Security and lifecycle regressions found during PR #33's explorer audit."""
2
+ import gzip
3
+
4
+ import httpx
5
+ import pytest
6
+ from fastapi.testclient import TestClient
7
+
8
+ from app import config, main, runner, spaces_live as live
9
+
10
+
11
+ def test_forwarded_addresses_require_a_trusted_proxy(monkeypatch):
12
+ from starlette.requests import Request
13
+ from app.http import client_ip
14
+ request = Request({"type": "http", "headers": [(b"x-forwarded-for", b"forged, proxy-appended")], "client": ("peer", 123)})
15
+ monkeypatch.setattr(config, "TRUST_PROXY", False)
16
+ assert client_ip(request) == "peer"
17
+ monkeypatch.setattr(config, "TRUST_PROXY", True)
18
+ assert client_ip(request) == "proxy-appended"
19
+
20
+
21
+ def test_streamed_gzip_has_a_decompression_output_cap(monkeypatch):
22
+ monkeypatch.setattr(live, "MAX_REPLY", 1024)
23
+ class Stream(httpx.SyncByteStream):
24
+ def __iter__(self):
25
+ yield gzip.compress(b"x" * 10_000_000)
26
+ with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, headers={"content-encoding": "gzip"}, stream=Stream()))) as c:
27
+ with pytest.raises(live.SpaceError, match="too large"):
28
+ c.get("https://fixture.hf.space/")
29
+
30
+
31
+ @pytest.mark.parametrize("headers", [
32
+ {"Origin": "https://evil.example", "X-Forwarded-Host": "evil.example"},
33
+ {"Origin": "http://testserver"},
34
+ {"Origin": "null"},
35
+ {"Origin": "https://testserver/path"},
36
+ {"Origin": "https://testserver@evil.example"},
37
+ ])
38
+ def test_origin_must_match_scheme_and_authority(monkeypatch, headers):
39
+ monkeypatch.setattr(config, "PUBLIC_URL", "")
40
+ with TestClient(main.app, base_url="https://testserver") as c:
41
+ assert c.post("/api/logout", headers=headers).status_code == 403
42
+
43
+
44
+ def test_valid_origin_and_explicit_public_origin(monkeypatch):
45
+ with TestClient(main.app, base_url="https://testserver") as c:
46
+ monkeypatch.setattr(config, "PUBLIC_URL", "")
47
+ assert c.post("/api/logout", headers={"Origin": "https://testserver:443"}).status_code == 200
48
+ monkeypatch.setattr(config, "PUBLIC_URL", "https://public.hf.space")
49
+ assert c.post("/api/logout", headers={"Origin": "https://public.hf.space"}).status_code == 200
50
+
51
+
52
+ def test_personal_api_responses_are_not_cached():
53
+ with TestClient(main.app, base_url="https://testserver") as c:
54
+ assert c.get("/api/runs").headers["cache-control"] == "private, no-store"
55
+ assert c.get("/api/me").headers["cache-control"] == "private, no-store"
56
+
57
+
58
+ @pytest.mark.parametrize("bad", [".", "..", "x\n", "x/y", "%2e%2e", "//elsewhere"])
59
+ def test_path_parameters_cannot_change_the_published_route(bad):
60
+ info = {"api": [{"method": "GET", "path": "/tasks/{id}", "params": ["id"]}]}
61
+ with pytest.raises(live.SpaceError):
62
+ live.route_url(info, "GET", "/tasks/{id}", {"id": bad})
63
+
64
+
65
+ def test_upstream_body_is_stopped_before_it_is_fully_buffered(monkeypatch):
66
+ monkeypatch.setattr(live, "MAX_REPLY", 64 * 1024)
67
+ reads = []
68
+
69
+ class Stream(httpx.SyncByteStream):
70
+ def __iter__(self):
71
+ for i in range(100):
72
+ reads.append(i)
73
+ yield b"x" * 64 * 1024
74
+
75
+ with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, stream=Stream()))) as c:
76
+ with pytest.raises(live.SpaceError, match="too large"):
77
+ c.get("https://fixture.hf.space/")
78
+ assert len(reads) <= 3
79
+
80
+
81
+ def test_gzip_reply_is_decoded_once_and_limited(monkeypatch):
82
+ monkeypatch.setattr(live, "MAX_REPLY", 1024)
83
+ def response(r):
84
+ return httpx.Response(200, content=gzip.compress(b'{"ok":true}'), headers={"Content-Encoding": "gzip"})
85
+ with live.SpaceClient(transport=httpx.MockTransport(response)) as c:
86
+ assert c.get("https://fixture.hf.space/").json() == {"ok": True}
87
+ with live.SpaceClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, content=gzip.compress(b"x" * 2048), headers={"Content-Encoding": "gzip"}))) as c:
88
+ with pytest.raises(live.SpaceError, match="too large"):
89
+ c.get("https://fixture.hf.space/")
90
+
91
+
92
+ def test_disconnected_episode_is_not_silently_replaced(monkeypatch):
93
+ s = live.Session("fixture/env", "owner")
94
+ class Socket:
95
+ def send(self, message): pass
96
+ def recv(self, timeout): raise TimeoutError
97
+ def close(self): pass
98
+ s.ws = Socket()
99
+ with pytest.raises(live.SpaceError) as error:
100
+ live._ws_send(s, {"type": "step", "data": {}}, 1)
101
+ assert error.value.status == 504
102
+ monkeypatch.setattr(live, "_open", lambda s: pytest.fail("must not create a replacement episode"))
103
+ with pytest.raises(live.SpaceError) as error:
104
+ live._ws_send(s, {"type": "step", "data": {}}, 1)
105
+ assert error.value.status == 410
106
+
107
+
108
+ @pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf"), 1.5])
109
+ def test_rollout_limits_reject_non_finite_and_fractional_values(value):
110
+ with pytest.raises(ValueError):
111
+ runner._check_fields([{"key": "steps", "type": "number", "step": 1, "min": 1, "max": 100}], {"steps": value})
112
+
113
+
114
+ def test_raw_file_names_are_unicode_safe_and_cannot_inject_headers(monkeypatch):
115
+ monkeypatch.setattr(main.registry, "raw", lambda *a: (b"hello", "text/plain"))
116
+ c = TestClient(main.app, base_url="https://testserver")
117
+ for name in ['文件.txt', 'x\r\nInjected: true.txt']:
118
+ r = c.get('/api/env/org/ds/raw', params={"ref": "train/0", "f": name, "download": "true"})
119
+ assert r.status_code == 200
120
+ assert r.headers['content-disposition'].startswith("attachment; filename*=UTF-8''")
121
+ assert '\r' not in r.headers['content-disposition'] and '\n' not in r.headers['content-disposition']
122
+ assert 'Injected' not in r.headers
123
+ assert r.headers['content-security-policy'] == 'sandbox'
124
+
125
+
126
+ def test_query_parameters_survive_route_discovery_and_are_encoded():
127
+ api = {"paths": {"/tasks/{id}": {"parameters": [{"name": "split", "in": "query", "required": True, "schema": {"type": "string"}}],
128
+ "get": {"parameters": [{"name": "limit", "in": "query", "schema": {"type": "integer", "default": 10}}]}}}}
129
+ info = {"api": live._api_routes(api, False)}
130
+ schema = info["api"][0]["query_schema"]
131
+ assert schema["required"] == ["split"] and schema["properties"]["limit"]["default"] == 10
132
+ assert live.route_url(info, "GET", "/tasks/{id}", {"id": "a"}, {"split": "x&admin=true"}) == '/tasks/a?split=x%26admin%3Dtrue'
133
+ with pytest.raises(live.SpaceError, match='required'):
134
+ live.route_url(info, "GET", "/tasks/{id}", {"id": "a"})
135
+ with pytest.raises(live.SpaceError, match='published'):
136
+ live.route_url(info, "GET", "/tasks/{id}", {"id": "a"}, {"url": "https://evil.example"})
137
+
138
+
139
+ def test_mcp_routes_separate_body_path_and_query_and_keep_grading_routes(monkeypatch):
140
+ from app import mcp_bridge
141
+ api = {"paths": {"/verify/{id}": {"post": {
142
+ "parameters": [{"name": "id", "in": "query", "schema": {"type": "string"}}],
143
+ "requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object", "properties": {"id": {"type": "integer"}}, "required": ["id"]}}}},
144
+ }}}}
145
+ info = {"api": live._api_routes(api, False), "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]}
146
+ tools = {t['name']: t for t in mcp_bridge._tools(info)}
147
+ assert {'look', 'verify_id'} == set(tools)
148
+ assert set(tools['verify_id']['inputSchema']['properties']) == {'path_params', 'query', 'body'}
149
+ captured = []
150
+ monkeypatch.setattr(mcp_bridge, '_play', lambda *a: 'session')
151
+ monkeypatch.setattr(live, 'act', lambda *args: captured.append(args[-1]) or {'result': {'json': {'reward': 1}, 'status': 200}})
152
+ args = {"path_params": {"id": "path-id"}, "query": {"id": "query-id"}, "body": {"id": 42}}
153
+ mcp_bridge._call('o/s', info, {"owner": "owner"}, 'verify_id', args)
154
+ assert captured[0]['params'] == args['path_params'] and captured[0]['body'] == args['body'] and captured[0]['query'] == args['query']
155
+
156
+
157
+ def test_mcp_rate_limiter_does_not_accumulate_rejected_requests():
158
+ from app import mcp_bridge
159
+ owner = 'rate-regression'
160
+ for _ in range(mcp_bridge.RATE[0] * 3):
161
+ mcp_bridge._limited(owner)
162
+ assert len(mcp_bridge._hits[owner]) == mcp_bridge.RATE[0]
163
+ mcp_bridge._hits.pop(owner)
tests/test_auto_index.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gzip
2
+ import json
3
+ import time
4
+
5
+ import pytest
6
+
7
+ from app import auto_index, config, indexer, snapshot
8
+
9
+
10
+ @pytest.fixture(autouse=True)
11
+ def storage(tmp_path, monkeypatch):
12
+ monkeypatch.setattr(config, "STORAGE_DIR", tmp_path)
13
+ monkeypatch.setattr(config, "CACHE_DIR", tmp_path / "cache")
14
+ monkeypatch.setenv("RLX_SNAPSHOT_STORE", str(tmp_path))
15
+
16
+
17
+ def test_recent_full_listing_is_reused_without_extending_discovery_age(monkeypatch):
18
+ at = time.time() - 300
19
+ rows = [{"id": "owner/env", "kind": "dataset"}]
20
+ (config.STORAGE_DIR / "listing.json.gz").write_bytes(gzip.compress(json.dumps({"at": at, "full": True, "rows": rows}).encode()))
21
+ called = []
22
+ def run(store, **kwargs):
23
+ called.append(kwargs)
24
+ return {"counts": {"envs": 1, "tasks": 2}}
25
+ monkeypatch.setattr(indexer, "run", run)
26
+ auto_index.cycle()
27
+ assert called == [{"rows": rows, "listing_at": at, "budget_s": 90, "max_builds": 4}]
28
+ assert auto_index.status()["state"] == "idle"
29
+ assert auto_index.status()["last_success"] >= at
30
+ auto_index.cycle()
31
+ assert len(called) == 1 # app restarts and duplicate workers honor cadence
32
+ assert auto_index.cached_listing(at + 3601) == (None, None)
33
+ assert auto_index.cached_listing(at - 1) == (None, None)
34
+
35
+
36
+ def test_locked_worker_cannot_publish_duplicate_snapshot(monkeypatch):
37
+ fcntl = pytest.importorskip("fcntl")
38
+ config.CACHE_DIR.mkdir()
39
+ monkeypatch.setattr(indexer, "run", lambda *a, **k: pytest.fail("duplicate indexer"))
40
+ with (config.CACHE_DIR / "auto-index.lock").open("a") as lease:
41
+ fcntl.flock(lease, fcntl.LOCK_EX | fcntl.LOCK_NB)
42
+ auto_index.cycle()
43
+ assert auto_index.status() == {}
44
+
45
+
46
+ def test_failure_preserves_published_pointer_and_backs_off(monkeypatch):
47
+ store = snapshot.open_store()
48
+ store.put_bytes(snapshot.POINTER, b"last-good-snapshot")
49
+ def fail(*a, **k):
50
+ raise ConnectionError("upstream response must not enter public status")
51
+ monkeypatch.setattr(indexer, "run", fail)
52
+ auto_index.cycle()
53
+ first = auto_index.status()
54
+ assert first["state"] == "retrying" and first["error"] == "ConnectionError"
55
+ assert first["next_at"] > time.time() + 590
56
+ assert store.read_bytes(snapshot.POINTER) == b"last-good-snapshot"
57
+ auto_index._save({**first, "next_at": 0})
58
+ auto_index.cycle()
59
+ assert auto_index.status()["failures"] == 2
60
+ assert auto_index.status()["next_at"] > time.time() + 1190
61
+
62
+
63
+ def test_missing_or_partial_listing_requires_fresh_discovery():
64
+ assert auto_index.cached_listing(time.time()) == (None, None)
65
+ doc = {"at": time.time(), "full": False, "rows": [{"id": "owner/partial"}]}
66
+ (config.STORAGE_DIR / "listing.json.gz").write_bytes(gzip.compress(json.dumps(doc).encode()))
67
+ assert auto_index.cached_listing(time.time()) == (None, None)
68
+
69
+
70
+ def test_shutdown_terminates_only_owned_indexer_child(monkeypatch):
71
+ calls = []
72
+ class Child:
73
+ def poll(self): return None
74
+ def terminate(self): calls.append("terminate")
75
+ def wait(self, **kwargs): calls.append("wait")
76
+ def popen(command, **kwargs):
77
+ assert command[-2:] == ["-m", "app.auto_index"]
78
+ assert kwargs["env"]["HF_HUB_DISABLE_IMPLICIT_TOKEN"] == "1"
79
+ return Child()
80
+ monkeypatch.setattr(auto_index.subprocess, "Popen", popen)
81
+ auto_index._stop.set()
82
+ try:
83
+ auto_index._child()
84
+ finally:
85
+ auto_index._stop.clear()
86
+ assert calls == ["terminate", "wait"]
tests/test_capabilities.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Support claims reflect runtime evidence, not a source's framework tag."""
2
+ import pytest
3
+
4
+ from app import spaces_live as live
5
+ from app.envs.capabilities import dataset_support, space_support
6
+
7
+
8
+ def state(support, feature):
9
+ return next(c["state"] for c in support["capabilities"] if c["id"] == feature)
10
+
11
+
12
+ @pytest.mark.parametrize("framework,runner", [("Verifiers", "verifiers"), ("NeMo Gym", "nemo-gym"), ("verl", "verl"), ("SkyRL", "skyrl")])
13
+ def test_native_framework_dataset_does_not_claim_local_execution(framework, runner):
14
+ support = dataset_support(framework, options=[{"runner": runner, "label": framework, "ok": False, "why": "Use its native harness"}])
15
+ assert state(support, "browse") == "available"
16
+ assert state(support, "run") == "external"
17
+ assert support["framework"]["docs"].startswith("https://")
18
+
19
+
20
+ def test_task_support_distinguishes_blocked_from_runnable():
21
+ options = [{"runner": "harbor", "label": "Harbor", "ok": False, "why": "GPU required"}]
22
+ assert state(dataset_support("Harbor", options=options), "run") == "unavailable"
23
+ options[0]["ok"] = True
24
+ assert state(dataset_support("Harbor", options=options), "run") == "available"
25
+ assert state(dataset_support("Harbor"), "run") == "unknown"
26
+
27
+
28
+ def test_sleeping_server_never_claims_live_capabilities():
29
+ s = space_support({"running": False, "stage": "SLEEPING", "last_seen": {"step_api": True}})
30
+ assert state(s, "play") == "unknown"
31
+ assert s["transport"] is None
32
+
33
+
34
+ @pytest.mark.parametrize("protocol", ["openenv", "gymnasium", "nemo-gym", "mcp", "api"])
35
+ def test_probe_uses_published_protocol_not_reset_route_or_tag(monkeypatch, protocol):
36
+ from app import catalog
37
+ catalog._memo.clear()
38
+ rec = {"id": "fixture/env", "stage": "RUNNING", "host": "https://fixture-env.hf.space", "base_path": None, "tags": ["openenv"]}
39
+ monkeypatch.setattr(live, "record", lambda *a, **k: rec)
40
+ paths = {"/reset": {"post": {}}, "/step": {"post": {}}} if protocol in {"openenv", "gymnasium"} else (
41
+ {"/seed_session": {"post": {}}, "/verify": {"post": {}}} if protocol == "nemo-gym" else {"/mcp": {"post": {}}} if protocol == "mcp" else {"/guess": {"post": {}}})
42
+ docs = {"/openapi.json": {"info": {"version": "1"}, "paths": paths}}
43
+ if protocol == "openenv":
44
+ docs["/schema"] = {k: {"type": "object", "properties": {}} for k in ("action", "observation", "state")}
45
+ monkeypatch.setattr(live, "_get_json", lambda rec, path: docs.get(path))
46
+ monkeypatch.setattr(live, "_ui_path", lambda rec: None)
47
+ monkeypatch.setattr(live, "_tools", lambda rec: ([{"name": "guess"}] if protocol == "mcp" else [], None, {}))
48
+ monkeypatch.setattr(live, "remember", lambda *a: None)
49
+ info = live.probe("fixture/env")
50
+ assert info["framework"] == protocol
51
+ assert info["openenv"] is (protocol == "openenv")
52
+ assert info["step_api"] is (protocol == "openenv")
53
+ if protocol == "gymnasium":
54
+ assert {r["path"] for r in info["api"]} == {"/reset", "/step"}
55
+ assert info["support"]["transport"] == "http"
56
+ assert state(info["support"], "play") == "available"
57
+
58
+
59
+ def test_private_dataset_does_not_offer_public_mcp():
60
+ assert state(dataset_support("Harbor", restricted=True), "mcp") == "unavailable"
61
+
62
+
63
+ def test_packed_harbor_rows_keep_the_harbor_lifecycle():
64
+ assert dataset_support("Harbor (packed)")["framework"]["id"] == "harbor"
65
+
66
+
67
+ def test_unknown_runner_is_not_claimed_as_hosted():
68
+ assert state(dataset_support("Custom", options=[{"runner": "custom", "label": "Custom", "ok": True}]), "run") == "unavailable"
tests/test_community.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import pytest
4
+ from fastapi.testclient import TestClient
5
+
6
+ from app import auth, community, main, settings, store
7
+
8
+ client = TestClient(main.app)
9
+
10
+
11
+ def record(id, runner="harbor", **fields):
12
+ return {"id": id, "dataset": f"org/{runner}", "path": "task/1", "title": "A task", "user": "hidden-user",
13
+ "runner": runner, "status": "done", "reward": 1, "visibility": "public", "model": "org/model",
14
+ "created_at": 10, **fields}
15
+
16
+
17
+ @pytest.fixture
18
+ def records(monkeypatch):
19
+ rows = [record("harbor", reward=-3, dataset="a/same"), record("mimo", "mimo", domain="code", reward=0.25),
20
+ record("nemo", "nemo-gym", reward=1, dataset="b/same"), record("future", "custom-runtime", reward=20),
21
+ record("private", visibility="private"), record("restricted", restricted=True),
22
+ record("failed", status="failed", reward=None), record("hidden")]
23
+ monkeypatch.setattr(store, "list_runs", lambda **kw: rows)
24
+ monkeypatch.setattr(store, "has_artifact", lambda *a: False)
25
+ monkeypatch.setattr(settings, "get", lambda key, default=None: ["hidden"] if key == "hidden_runs" else default)
26
+ return rows
27
+
28
+
29
+ def test_all_saved_runners_share_the_feed_with_scoped_counts(records):
30
+ data = client.get("/api/community").json()
31
+ assert {r["id"] for r in data["runs"]} == {"harbor", "mimo", "nemo", "future"}
32
+ assert data["total"] == data["stats"]["runs"] == 4
33
+ assert data["stats"]["datasets"] == data["stats"]["runners"] == 4
34
+ assert not data["stats"]["comparable"]
35
+ assert "mean" not in data["stats"] # no meaningless global reward average
36
+ assert {r["group"] for r in data["runs"]} >= {"a/same", "b/same"}
37
+ assert "hidden-user" not in json.dumps(data)
38
+ for runner in ("harbor", "mimo", "nemo-gym", "custom-runtime"):
39
+ subset = client.get("/api/community", params={"runner": runner}).json()
40
+ assert subset["total"] == subset["stats"]["runs"] == subset["stats"]["datasets"] == 1
41
+ assert {r["runner"] for r in subset["runs"]} == {runner}
42
+ assert len(subset["facets"]["runner"]) == 4 # own facet retains alternatives
43
+ assert len(subset["facets"]["dataset"]) == 1
44
+
45
+
46
+ def test_search_includes_environment_runner_and_model_and_empty_counts(records):
47
+ for query, expected in (("b/same", "nemo"), ("nemo-gym", "nemo"), ("org/mimo model", "mimo")):
48
+ data = client.get("/api/community", params={"q": query}).json()
49
+ assert [r["id"] for r in data["runs"]] == [expected]
50
+ empty = client.get("/api/community?dataset=nobody/missing").json()
51
+ assert empty["total"] == empty["stats"]["datasets"] == empty["stats"]["models"] == empty["stats"]["runners"] == 0
52
+ assert empty["stats"]["results"] == []
53
+
54
+
55
+ def test_reward_summaries_separate_metrics_domains_judges_and_revisions():
56
+ rows = [record("a", reward=-4), record("b", reward=-2)]
57
+ for field, value in (("reward_key", "latency"), ("domain", "code"), ("judge", "judge/new"), ("sha", "new-revision")):
58
+ rows.append(record(field, reward=100, **{field: value}))
59
+ data = community.stats(rows)
60
+ assert len(data["results"]) == 5
61
+ original = next(r for r in data["results"] if r["runs"] == 2)
62
+ assert original["mean"] == -3 and original["max"] == -2
63
+ assert community.stats([record("a", reward=1e308), record("b", reward=1e308)])["results"][0]["mean"] == 1e308
64
+
65
+
66
+ @pytest.mark.parametrize("value", [None, "1", True, {}, float("nan"), float("inf"), 10**1000])
67
+ def test_invalid_rewards_cannot_break_or_enter_public_feed(value, monkeypatch):
68
+ monkeypatch.setattr(settings, "get", lambda *a: [])
69
+ assert not main._shareable(record("bad", reward=value))
70
+
71
+
72
+ def test_pagination_sort_and_negative_reward_band(records):
73
+ records.extend(record(f"r{i:02}", created_at=i, reward=-i, cost={"total": None}) for i in range(20))
74
+ all_rows = client.get("/api/community?sort=reward_asc").json()["runs"]
75
+ pages = [client.get("/api/community", params={"sort": "reward_asc", "offset": i, "limit": 5}).json()["runs"] for i in range(0, 24, 5)]
76
+ assert [r["id"] for page in pages for r in page] == [r["id"] for r in all_rows]
77
+ negative = client.get("/api/community?reward=zero").json()
78
+ assert negative["total"] == 21 and all(r["reward"] <= 0 for r in negative["runs"])
79
+ assert client.get("/api/community?sort=cost_asc").status_code == 200
80
+
81
+
82
+ @pytest.mark.parametrize("runner,domain,events", [("harbor", None, False), ("mimo", "code", True), ("nemo-gym", "nemo-gym", True), (None, "code", True)])
83
+ def test_trace_routes_match_runner_and_keep_public_scrubbing(monkeypatch, runner, domain, events):
84
+ from app.mimo.runner import core
85
+ r = record("trace", runner, domain=domain)
86
+ monkeypatch.setattr(store, "get", lambda id: r)
87
+ monkeypatch.setattr(settings, "get", lambda *a: [])
88
+ monkeypatch.setattr(auth, "current_user", lambda request: None)
89
+ monkeypatch.setattr(store, "read_artifact", lambda *a: b'[{"content":"hidden-user"}]')
90
+ monkeypatch.setattr(main.runner, "is_live", lambda *a: False)
91
+ monkeypatch.setattr(core, "live", lambda *a: None)
92
+ monkeypatch.setattr(core, "events", lambda *a: [{"content": "hidden-user"}])
93
+ response = client.get("/api/runs/trace")
94
+ assert response.status_code == 200 and "hidden-user" not in response.text
95
+ assert ("events" in response.json()) == events
96
+ assert response.json()["run"]["runner"] == (runner or "mimo")
tests/test_deploy_spaces.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from scripts import deploy_spaces
2
+
3
+
4
+ def test_upload_manifest_excludes_local_secrets_and_symlinks(tmp_path, monkeypatch):
5
+ for name in ('app/main.py','web/app.css','.env','.env.production','sub/.env.local','sub/private.key','.local-data/run.json','.venv/env.py'):
6
+ path=tmp_path/name
7
+ path.parent.mkdir(parents=True,exist_ok=True)
8
+ path.write_text('sample')
9
+ (tmp_path/'linked.py').symlink_to(tmp_path/'.env')
10
+ (tmp_path/'linked-dir').symlink_to(tmp_path/'.local-data',target_is_directory=True)
11
+ monkeypatch.setattr(deploy_spaces,'ROOT',tmp_path)
12
+ assert deploy_spaces.files()==['app/main.py','web/app.css']
tests/test_discovery.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Discovery evidence is metadata, not a runtime attestation or an editorial decision."""
2
+ from types import SimpleNamespace
3
+
4
+ import pytest
5
+ from huggingface_hub import SpaceInfo
6
+
7
+ from app import catalog
8
+
9
+
10
+ def space(sid="org/env", *, tags=(), files=(), **kw):
11
+ return SpaceInfo(id=sid, sdk="docker", private=False, tags=list(tags), likes=0,
12
+ siblings=[{"rfilename": f} for f in files], **kw)
13
+
14
+
15
+ @pytest.mark.parametrize("files", [("openenv.yaml",), ("my_env/openenv.yml",)])
16
+ def test_manifest_discovery(files):
17
+ d = catalog._space_summary(space(files=files))
18
+ assert d["openenv"] and d["manifest"] == files[0]
19
+
20
+
21
+ @pytest.mark.parametrize("file", ["src/openenv.yaml", "templates/openenv.yaml", "examples/openenv.yaml",
22
+ "build/openenv.yaml", "vendor/openenv.yaml", "envs/echo/openenv.yaml",
23
+ "src/openenv/cli/templates/openenv.yaml", ".venv/openenv.yaml"])
24
+ def test_vendored_manifests_are_not_declarations(file):
25
+ d = catalog._space_summary(space(files=[file]))
26
+ assert not d["openenv"] and d["manifest"] is None
27
+
28
+
29
+ @pytest.mark.parametrize("tag", ["openenv", "library:openenv", "openenv-0.7.0"])
30
+ def test_tag_only_remains_a_candidate_without_a_manifest(tag):
31
+ d = catalog._space_summary(space(tags=[tag]))
32
+ assert d["openenv"] and d["manifest"] is None
33
+
34
+
35
+ def test_names_and_generic_rl_mcp_tags_do_not_establish_openenv():
36
+ d = catalog._space_summary(space("openenv/not-an-env", tags=["rl-environment", "mcp-server"]))
37
+ assert not d["openenv"] and d["mcp"]
38
+
39
+
40
+ def test_space_census_deduplicates_and_applies_visibility_to_featured(monkeypatch):
41
+ good = space(tags=["openenv"])
42
+ article = space("org/article", tags=["openenv", "research-article-template"])
43
+ private = space("org/private"); private.private = True
44
+ static = space("org/static"); static.sdk = "static"
45
+ by = {s.id: s for s in [good, article, private, static]}
46
+ monkeypatch.setattr(catalog, "collections", lambda: [{"ids": [f"space:{s}" for s in by]}])
47
+ monkeypatch.setattr(catalog, "_api", lambda: SimpleNamespace(list_spaces=lambda **kw: iter(by.values()),
48
+ space_info=lambda sid, **kw: by[sid]))
49
+ assert [r["id"] for r in catalog.spaces()] == [good.id]
50
+
51
+
52
+ def test_failed_full_census_is_not_silently_published(monkeypatch):
53
+ def listing(**kw):
54
+ yield space(tags=["openenv"])
55
+ raise ConnectionError("page two unavailable")
56
+
57
+ monkeypatch.setattr(catalog, "collections", lambda: [])
58
+ monkeypatch.setattr(catalog, "_api", lambda: SimpleNamespace(list_spaces=listing))
59
+ with pytest.raises(ConnectionError):
60
+ catalog.spaces(full=True)
61
+ assert len(catalog.spaces(full=False)) == 1 # a quick bootstrap may serve candidates while refreshing
62
+
63
+
64
+ def test_collection_never_reclassifies_framework(monkeypatch):
65
+ d = catalog._space_summary(space(tags=["ors"]))
66
+ monkeypatch.setattr(catalog, "_listing_rows", lambda: [d])
67
+ monkeypatch.setattr(catalog, "collections", lambda: [{"id": "fineenvs", "ids": [d["key"]]}])
68
+ monkeypatch.setattr(catalog, "hidden", lambda: set())
69
+ assert catalog.environments()[0]["framework"] == "ors"
tests/test_frameworks.py CHANGED
@@ -135,7 +135,9 @@ def test_nemo_gym_pivot_row_reads_as_a_conversation_and_hides_the_experts_next_s
135
  assert "--agent toolcall_schema_single_step_tool_use_with_argument_comparison_agent" in run and "gym eval run --no-serve" in run
136
  assert "--repo-id nvidia/Nemotron-RL-Agentic-Function-Calling-Pivot-v1" in run and "sed -n '1p'" in run
137
  opt = view["framework_run"]
138
- assert opt["runner"] == "nemo-gym" and opt["ok"] is False and "NeMo Gym" in opt["why"]
 
 
139
  assert ["Step", "turn 6 · step 1"] in view["glance"] and card["chips"][0] == "turn 6 · step 1"
140
 
141
 
@@ -210,6 +212,8 @@ def test_nemo_gym_blends_route_each_row_and_run_without_one_agent():
210
  def test_nemo_gym_catalog_is_current_and_complete():
211
  cat = nemogym.catalog()
212
  assert len(cat["commit"]) == 40 and len(cat["agents"]) > 100 and len(cat["servers"]) > 100
 
 
213
  for name in ("mcqa_simple_agent", "workplace_assistant_simple_agent", "calendar_simple_agent", "code_gen_simple_agent",
214
  "instruction_following_simple_agent", "reasoning_gym_simple_agent"):
215
  a = cat["agents"][name]
@@ -406,3 +410,11 @@ def test_search_never_finds_a_row_through_its_withheld_answer(monkeypatch):
406
  files = rows.Files.__new__(rows.Files)
407
  monkeypatch.setattr(rows.Files, "_scan", lambda self, c, s, o, match, what: ({"rows": [h for h in found if match(h[1])]}, None))
408
  assert [i for i, _, _ in files.search("d", "train", "7", 0)[0]["rows"]] == []
 
 
 
 
 
 
 
 
 
135
  assert "--agent toolcall_schema_single_step_tool_use_with_argument_comparison_agent" in run and "gym eval run --no-serve" in run
136
  assert "--repo-id nvidia/Nemotron-RL-Agentic-Function-Calling-Pivot-v1" in run and "sed -n '1p'" in run
137
  opt = view["framework_run"]
138
+ # This browsing fixture mixes Chat Completions and Responses tool formats;
139
+ # execution requires the dataset's native Responses function definitions.
140
+ assert opt["runner"] == "nemo-gym" and opt["ok"] is False and "native NeMo Gym" in opt["why"]
141
  assert ["Step", "turn 6 · step 1"] in view["glance"] and card["chips"][0] == "turn 6 · step 1"
142
 
143
 
 
212
  def test_nemo_gym_catalog_is_current_and_complete():
213
  cat = nemogym.catalog()
214
  assert len(cat["commit"]) == 40 and len(cat["agents"]) > 100 and len(cat["servers"]) > 100
215
+ assert cat["servers"]["instruction_following"]["verification"] == "Rule-based instruction checks"
216
+ assert cat["servers"]["math_proof_judgement"]["verification"] == "Deterministic answer parsing"
217
  for name in ("mcqa_simple_agent", "workplace_assistant_simple_agent", "calendar_simple_agent", "code_gen_simple_agent",
218
  "instruction_following_simple_agent", "reasoning_gym_simple_agent"):
219
  a = cat["agents"][name]
 
410
  files = rows.Files.__new__(rows.Files)
411
  monkeypatch.setattr(rows.Files, "_scan", lambda self, c, s, o, match, what: ({"rows": [h for h in found if match(h[1])]}, None))
412
  assert [i for i, _, _ in files.search("d", "train", "7", 0)[0]["rows"]] == []
413
+
414
+
415
+ def test_verifiers_v1_taskdata_system_prompt_is_part_of_the_conversation():
416
+ row = {"prompt": "Solve the task", "system_prompt": "Use the supplied tools", "answer": "PRIVATE_REFERENCE"}
417
+ view, _, _, _, text = everything(verifiers_rows.Verifiers(), row, tags=["verifiers"])
418
+ turns = next(s for s in view["sections"] if s["id"] == "task")["body"][0]["data"]["turns"]
419
+ assert [(t["role"], t["text"]) for t in turns] == [("system", "Use the supplied tools"), ("user", "Solve the task")]
420
+ assert "PRIVATE_REFERENCE" not in text
tests/test_live.py CHANGED
@@ -352,7 +352,7 @@ OPENAPI = {"paths": {
352
 
353
  def test_routes_are_plain_paths_on_non_openenv_servers_only():
354
  routes = live._api_routes(OPENAPI, openenv=False)
355
- assert {(r["method"], r["path"]) for r in routes} == {("POST", "/guess"), ("GET", "/tasks/{task_id}"), ("POST", "/self")}
356
  guess = next(r for r in routes if r["path"] == "/guess")
357
  assert guess["schema"]["properties"]["word"] == {"type": "string", "title": "Word"} and guess["schema"]["required"] == ["word"]
358
  assert next(r for r in routes if r["path"] == "/tasks/{task_id}")["params"] == ["task_id"]
@@ -383,8 +383,8 @@ def test_route_calls_keep_their_cookies_and_withhold_answers_from_gets(monkeypat
383
  return httpx.Response(200, json={"output": "ok"}, headers={"set-cookie": "session=abc; path=/"})
384
  return httpx.Response(200, json={"task_id": "t1", "prompt": "p", "answer": "SECRET", "meta": {"ground_truth": "TRUTHVAL"}})
385
 
386
- real = httpx.Client
387
- monkeypatch.setattr(live.httpx, "Client", lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
388
  sid = live.start("org/env", "web:a")["session"]
389
  out = live.act(sid, "web:a", "http", {"method": "POST", "path": "/guess", "body": {"word": "crane"}})["result"]
390
  assert out["status"] == 200 and out["json"] == {"output": "ok"}
@@ -397,7 +397,7 @@ def test_route_calls_keep_their_cookies_and_withhold_answers_from_gets(monkeypat
397
 
398
  def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
399
  """No MCP tools of its own (OpenEnv answers "Environment does not support MCP"): the page gets exactly what the MCP
400
- bridge offers an agent, step taking the server's own action fields. A tool server gets nothing added."""
401
  info = {"running": True, "stage": "RUNNING", "step_api": True, "mcp": None, "task_api": None,
402
  "schema": {"action": {"type": "object", "properties": {"latex": {"type": "string", "description": "Predicted LaTeX"}}, "required": ["latex"]}}}
403
  monkeypatch.setattr(live, "probe", lambda spec, fresh=False: info)
@@ -406,4 +406,4 @@ def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
406
  assert "latex" in json.dumps(tools["step"]["inputSchema"])
407
  assert tools == {t["name"]: t for t in mcp_bridge._tools(info)} # the page and the bridge never disagree
408
  monkeypatch.setattr(live, "probe", lambda spec, fresh=False: {**info, "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]})
409
- assert "bridge_tools" not in client.get("/api/spaces/o/geo/live").json()
 
352
 
353
  def test_routes_are_plain_paths_on_non_openenv_servers_only():
354
  routes = live._api_routes(OPENAPI, openenv=False)
355
+ assert {(r["method"], r["path"]) for r in routes} == {("POST", "/guess"), ("GET", "/tasks/{task_id}"), ("POST", "/self"), ("POST", "/reset")}
356
  guess = next(r for r in routes if r["path"] == "/guess")
357
  assert guess["schema"]["properties"]["word"] == {"type": "string", "title": "Word"} and guess["schema"]["required"] == ["word"]
358
  assert next(r for r in routes if r["path"] == "/tasks/{task_id}")["params"] == ["task_id"]
 
383
  return httpx.Response(200, json={"output": "ok"}, headers={"set-cookie": "session=abc; path=/"})
384
  return httpx.Response(200, json={"task_id": "t1", "prompt": "p", "answer": "SECRET", "meta": {"ground_truth": "TRUTHVAL"}})
385
 
386
+ real = live.SpaceClient
387
+ monkeypatch.setattr(live, "SpaceClient", lambda **kw: real(transport=httpx.MockTransport(handler), **kw))
388
  sid = live.start("org/env", "web:a")["session"]
389
  out = live.act(sid, "web:a", "http", {"method": "POST", "path": "/guess", "body": {"word": "crane"}})["result"]
390
  assert out["status"] == 200 and out["json"] == {"output": "ok"}
 
397
 
398
  def test_a_reset_step_server_still_lists_its_tools(monkeypatch):
399
  """No MCP tools of its own (OpenEnv answers "Environment does not support MCP"): the page gets exactly what the MCP
400
+ bridge offers an agent, step taking the server's own action fields. Tool servers use the same descriptor."""
401
  info = {"running": True, "stage": "RUNNING", "step_api": True, "mcp": None, "task_api": None,
402
  "schema": {"action": {"type": "object", "properties": {"latex": {"type": "string", "description": "Predicted LaTeX"}}, "required": ["latex"]}}}
403
  monkeypatch.setattr(live, "probe", lambda spec, fresh=False: info)
 
406
  assert "latex" in json.dumps(tools["step"]["inputSchema"])
407
  assert tools == {t["name"]: t for t in mcp_bridge._tools(info)} # the page and the bridge never disagree
408
  monkeypatch.setattr(live, "probe", lambda spec, fresh=False: {**info, "mcp": [{"name": "look", "inputSchema": {"type": "object"}}]})
409
+ assert {t["name"] for t in client.get("/api/spaces/o/geo/live").json()["bridge_tools"]} == {"look", "reset", "state"}