Download app/spaces_live.py from githubtailscaleduplicity342/RL-Explorer: direct link, hf CLI and curl.
- Browser
- Download file 52.5 kB
-
https://huggingface.co/spaces/githubtailscaleduplicity342/RL-Explorer/resolve/main/app/spaces_live.py
- Command line
-
hf download hf://spaces/githubtailscaleduplicity342/RL-Explorer/app/spaces_live.py
-
curl -L -o spaces_live.py https://huggingface.co/spaces/githubtailscaleduplicity342/RL-Explorer/resolve/main/app/spaces_live.py
52.5 kB
| """Live environment Spaces: what a running OpenEnv server offers, waking or restarting it, its Task API, and a | |
| playground session on it (reset/step over its WebSocket, or its MCP tools). | |
| Everything here talks to one host only: the Space's own `https://<subdomain>.hf.space`, taken from the Hub's record | |
| of a public Space, never from the visitor. Nothing a visitor sends is passed on as a URL. | |
| What a server offers comes from its own `/openapi.json` (every OpenEnv server is FastAPI), its `/metadata` and | |
| `/schema`, `/list_environments` (the Task API), and `tools/list` on `/mcp`. Its web UI is found by asking for the | |
| `base_path` its README declares, then `/web`, then `/`. | |
| The last good probe of each Space is kept (STORAGE_DIR/space-probes), so a Space that has gone to sleep still shows | |
| what it offered when it was last seen running, and how it did against `openenv validate`'s six runtime criteria. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| import json | |
| import re | |
| import secrets | |
| import threading | |
| import time | |
| import zlib | |
| from urllib.parse import urlencode | |
| from concurrent.futures import ThreadPoolExecutor | |
| from typing import Any | |
| import httpx | |
| from . import catalog, config | |
| HOST = re.compile(r"^https://[a-z0-9-]+\.hf\.space$") | |
| UP = "RUNNING" | |
| DOWN = {"SLEEPING", "PAUSED", "STOPPED", "RUNTIME_ERROR", "BUILD_ERROR", "CONFIG_ERROR", "NO_APP_FILE", "DELETING"} | |
| STARTING = {"APP_STARTING", "BUILDING", "RUNNING_BUILDING", "RUNNING_APP_STARTING"} | |
| # Task fields that may hold the answer: left out of the task browser (a playground's terminal observation is the | |
| # environment's own feedback, so it is shown as the environment sends it) | |
| ANSWER = re.compile(r"(gold|answer|solution|expected|reference|ground_?truth|oracle|^target(?!_?lang)|_target$|^true_|correct|^label$|^labels$)", re.I) | |
| STANDARD_ROUTES = {"/reset", "/step", "/state", "/metadata", "/health", "/healthz", "/schema", "/mcp", "/list_environments", | |
| "/{env_name}/splits", "/{env_name}/tasks", "/{env_name}/num_tasks", "/{env_name}/task", "/{env_name}/task_range", | |
| "/web/metadata", "/web/reset", "/web/step", "/web/state", "/docs", "/redoc", "/openapi.json"} | |
| MAX_REPLY = 12 * 2**20 # bytes from a Space for one reply (observations can carry images) | |
| _lock = threading.Lock() | |
| class SpaceError(Exception): | |
| """A problem worth showing as it is (the Space is asleep, a call failed): becomes a 4xx/5xx with this text.""" | |
| def __init__(self, message: str, status: int = 409): | |
| super().__init__(message) | |
| self.status = status | |
| class SpaceClient(httpx.Client): | |
| """Bound decompressed replies while receiving them, including chunked bodies. | |
| Space operators control their responses. Checking Response.content after a | |
| normal get() is too late: httpx has already buffered the complete response. | |
| Keep httpx's cookie jar and connection pooling, but never buffer without a cap. | |
| """ | |
| def send(self, request, *, stream=False, **kwargs): | |
| # Negotiate only a codec with a bounded decompression API. | |
| request.headers["Accept-Encoding"] = "gzip, identity" | |
| response = super().send(request, stream=True, **kwargs) | |
| try: | |
| body = bytearray() | |
| codec = response.headers.get("content-encoding", "identity").lower() | |
| decoded = response.is_stream_consumed | |
| if not decoded and codec not in {"identity", "gzip"}: | |
| raise SpaceError("the Space used an unsupported response encoding", 502) | |
| decoder = zlib.decompressobj(16 + zlib.MAX_WBITS) if not decoded and codec == "gzip" else None | |
| chunks = (response.content,) if decoded else response.iter_raw(chunk_size=64 * 1024) | |
| for chunk in chunks: | |
| if decoder: | |
| chunk = decoder.decompress(chunk, MAX_REPLY - len(body) + 1) | |
| if len(body) + len(chunk) > MAX_REPLY: | |
| raise SpaceError("the Space's reply is too large to show", 502) | |
| body.extend(chunk) | |
| if decoder and (not decoder.eof or decoder.unused_data): | |
| raise SpaceError("the Space returned an invalid compressed response", 502) | |
| headers = [(k, v) for k, v in response.headers.multi_items() | |
| if k.lower() not in ("content-encoding", "content-length", "transfer-encoding")] | |
| return httpx.Response(response.status_code, headers=headers, content=bytes(body), request=request) | |
| except zlib.error: | |
| raise SpaceError("the Space returned an invalid compressed response", 502) from None | |
| finally: | |
| response.close() | |
| _client = SpaceClient(timeout=httpx.Timeout(12, connect=6), follow_redirects=False, | |
| headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"}, | |
| limits=httpx.Limits(max_connections=64, max_keepalive_connections=16)) | |
| # ββ the Space's record βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def record(spec: str, fresh: bool = False) -> dict[str, Any]: | |
| """Host, stage and declared UI path of a public Space (cached a few seconds: the page polls it while it wakes).""" | |
| spec = catalog.check_spec(spec) | |
| def fetch(): | |
| sp = catalog._api().space_info(spec, expand=["runtime", "subdomain", "cardData", "private", "sdk", "tags"]) | |
| if sp.private: | |
| raise PermissionError("this Space is private") | |
| card = (sp.card_data.to_dict() if sp.card_data else {}) or {} | |
| host = f"https://{sp.subdomain}.hf.space" if getattr(sp, "subdomain", None) else "" | |
| stage = str(getattr(sp.runtime, "stage", "") or "") if sp.runtime is not None else "" | |
| base = card.get("base_path") if isinstance(card.get("base_path"), str) else None | |
| return {"id": spec, "host": host if HOST.match(host) else "", "stage": stage or "UNKNOWN", "sdk": sp.sdk, | |
| "base_path": base if base and re.match(r"^/(?!/)[\w\-./]{0,80}$", base) and ".." not in base else None, | |
| "hardware": getattr(sp.runtime, "hardware", None) if sp.runtime is not None else None, | |
| "tags": list(sp.tags or [])} | |
| if fresh: | |
| catalog._memo.pop(("space-record", spec), None) | |
| return catalog._cached(("space-record", spec), 4, fetch) | |
| def _url(rec: dict, path: str) -> str: | |
| if not rec["host"]: | |
| raise SpaceError("this Space has no app address", 404) | |
| return rec["host"] + path | |
| def _json(r: httpx.Response) -> Any: | |
| if len(r.content) > MAX_REPLY: | |
| raise SpaceError("the Space's reply is too large to show", 502) | |
| body = r.text | |
| if "text/event-stream" in r.headers.get("content-type", ""): # a single SSE message: its data line is the reply | |
| body = next((ln[5:].strip() for ln in body.splitlines() if ln.startswith("data:")), "null") | |
| return json.loads(body) | |
| # ββ what a running server offers βββββββββββββββββββββββββββββββββββββββββββββ | |
| def _get_here(rec: dict, path: str, hops: int = 3) -> httpx.Response | None: | |
| """GET a path on the Space, following redirects only while they stay on its own host (never to another address).""" | |
| url = _url(rec, path) | |
| for _ in range(hops + 1): | |
| r = _client.get(url, timeout=8) | |
| if r.status_code not in (301, 302, 303, 307, 308): | |
| return r | |
| nxt = r.url.join(r.headers.get("location", "")) | |
| if nxt.scheme != "https" or nxt.host != httpx.URL(rec["host"]).host or nxt.port not in (None, 443) or nxt.userinfo: | |
| return None | |
| url = str(nxt) | |
| return None | |
| def _ui_path(rec: dict) -> str | None: | |
| """The path that serves a page meant for people: the README's base_path, OpenEnv's /web, or the root.""" | |
| for cand in [rec.get("base_path"), "/web", "/"]: | |
| if not cand: | |
| continue | |
| try: | |
| r = _get_here(rec, cand) | |
| except Exception: # noqa: BLE001 - slow or down: try the next | |
| continue | |
| if (r is not None and r.status_code == 200 and "text/html" in r.headers.get("content-type", "") | |
| and not r.url.path.rstrip("/").endswith(("/docs", "/redoc"))): | |
| return r.url.path or "/" | |
| return None | |
| def _get_json(rec: dict, path: str) -> Any: | |
| try: | |
| r = _client.get(_url(rec, path)) | |
| if r.status_code == 200 and "json" in r.headers.get("content-type", ""): | |
| return _json(r) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| return None | |
| def mcp_call(rec: dict, method: str, params: dict | None = None, timeout: float = 30, raw: bool = False) -> Any: | |
| """One JSON-RPC call to the Space's /mcp. Returns `result`; raises SpaceError with the server's message on error. | |
| `raw`: the whole JSON-RPC reply, an error reply included (HTTP failures still raise).""" | |
| if rec.get("mcp_standard"): | |
| with SpaceClient(timeout=httpx.Timeout(timeout, connect=6), follow_redirects=False) as client: | |
| remote = RemoteMCP(rec, client) | |
| try: | |
| return remote.call(method, params or {}, timeout, raw=raw) | |
| finally: | |
| remote.close() | |
| try: | |
| r = _client.post(_url(rec, "/mcp"), timeout=httpx.Timeout(timeout, connect=6), | |
| headers={"Accept": "application/json, text/event-stream", "Content-Type": "application/json"}, | |
| json={"jsonrpc": "2.0", "id": secrets.randbelow(10**9), "method": method, "params": params or {}}) | |
| except httpx.TimeoutException: | |
| raise SpaceError(f"the Space didn't answer within {int(timeout)} s", 504) from None | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the Space: {type(e).__name__}", 502) from None | |
| if r.status_code != 200: | |
| raise SpaceError(f"the Space's /mcp answered HTTP {r.status_code}", 502) | |
| try: | |
| doc = _json(r) | |
| except ValueError: | |
| raise SpaceError("the Space's /mcp didn't answer JSON", 502) from None | |
| if not isinstance(doc, dict): | |
| raise SpaceError("the Space's /mcp answered something unexpected", 502) | |
| if raw: | |
| return doc | |
| if doc.get("error"): | |
| err = doc["error"] if isinstance(doc["error"], dict) else {"message": str(doc["error"])} | |
| raise SpaceError(str(err.get("message") or "error")[:500], 422) | |
| return doc.get("result") | |
| class RemoteMCP: | |
| """A Streamable HTTP MCP session, isolated along with the visitor's cookies. | |
| No credentials from the explorer are forwarded. Expiry never replays an action: | |
| callers must explicitly open a new episode after a 410 response. | |
| """ | |
| def __init__(self, rec: dict, client: httpx.Client): | |
| self.url = _url(rec, "/mcp") | |
| self.client = client | |
| self.session = None | |
| self.version = None | |
| self.initialized = False | |
| self.expired = False | |
| def headers(self): | |
| headers = {"Accept": "application/json, text/event-stream", "Content-Type": "application/json"} | |
| if self.session: | |
| headers["Mcp-Session-Id"] = self.session | |
| if self.version: | |
| headers["MCP-Protocol-Version"] = self.version | |
| return headers | |
| def request(self, method, params, timeout, *, notification=False): | |
| rid = secrets.randbelow(10**9) | |
| message = {"jsonrpc": "2.0", "method": method, "params": params} | |
| if not notification: | |
| message["id"] = rid | |
| try: | |
| r = self.client.post(self.url, headers=self.headers(), json=message, timeout=httpx.Timeout(timeout, connect=6)) | |
| except httpx.TimeoutException: | |
| raise SpaceError("the MCP server timed out; the action was not retried", 504) from None | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the MCP server: {type(e).__name__}", 502) from None | |
| if self.session and r.status_code == 404: | |
| self.expired = True | |
| raise SpaceError("the MCP session has expired: start a new session", 410) | |
| if notification and r.status_code in (200, 202, 204): | |
| return {} | |
| if r.status_code != 200: | |
| raise SpaceError(f"the Space's /mcp answered HTTP {r.status_code}", 502) | |
| try: | |
| if "text/event-stream" in r.headers.get("content-type", ""): | |
| # Notifications can precede the response; SSE data may span lines. | |
| docs = [json.loads("\n".join(line[5:].lstrip() for line in event.splitlines() if line.startswith("data:"))) | |
| for event in r.text.replace("\r\n", "\n").split("\n\n") if any(line.startswith("data:") for line in event.splitlines())] | |
| doc = next((d for d in docs if isinstance(d, dict) and d.get("id") == rid), None) | |
| else: | |
| doc = r.json() | |
| except ValueError: | |
| raise SpaceError("the MCP server returned invalid JSON", 502) from None | |
| if not isinstance(doc, dict) or doc.get("jsonrpc") != "2.0" or doc.get("id") != rid: | |
| raise SpaceError("the MCP server returned no matching JSON-RPC response", 502) | |
| if method == "initialize" and not doc.get("error"): | |
| sid = r.headers.get("mcp-session-id") | |
| if sid and (len(sid) > 2048 or not all(0x21 <= ord(c) <= 0x7e for c in sid)): | |
| raise SpaceError("the MCP server returned an invalid session ID", 502) | |
| self.session = sid | |
| return doc | |
| def call(self, method, params, timeout, *, raw=False): | |
| if self.expired: | |
| raise SpaceError("the MCP session has expired: start a new session", 410) | |
| if not self.initialized: | |
| init = self.request("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, | |
| "clientInfo": {"name": "hf-rl-explorer", "version": "0.1.0"}}, timeout) | |
| error = init.get("error") or {} | |
| if not isinstance(error, dict): | |
| raise SpaceError("the MCP server returned an invalid initialization error", 502) | |
| if error.get("code") != -32601: # compatibility with simple JSON-RPC tool servers | |
| result = init.get("result") or {} | |
| if not isinstance(result, dict): | |
| raise SpaceError("the MCP server returned an invalid initialization result", 502) | |
| self.version = result.get("protocolVersion") | |
| if error or self.version not in {"2025-03-26", "2025-06-18", "2025-11-25"}: | |
| raise SpaceError("the MCP server could not negotiate a supported protocol", 502) | |
| self.request("notifications/initialized", {}, timeout, notification=True) | |
| self.initialized = True | |
| doc = self.request(method, params, timeout) | |
| if raw: | |
| return doc | |
| if doc.get("error"): | |
| error = doc["error"] | |
| raise SpaceError(str(error.get("message", "MCP error") if isinstance(error, dict) else error)[:500], 422) | |
| return doc.get("result") | |
| def close(self): | |
| if self.session and not self.expired: | |
| try: | |
| self.client.delete(self.url, headers=self.headers(), timeout=3) | |
| except Exception: # best-effort cleanup of an untrusted remote server | |
| pass | |
| self.expired = True | |
| def _tools(rec: dict) -> tuple[list[dict] | None, str | None, dict]: | |
| """Its MCP tools (or why there are none), and how /mcp answered: a JSON-RPC 2.0 reply, as `openenv validate` | |
| asks of it, or the failure.""" | |
| try: | |
| doc = mcp_call(rec, "tools/list", timeout=10, raw=True) | |
| except SpaceError as e: | |
| return None, str(e), {"ok": False, "detail": str(e)} | |
| rpc = {"ok": doc.get("jsonrpc") == "2.0", "detail": "JSON-RPC 2.0" if doc.get("jsonrpc") == "2.0" else "not a JSON-RPC 2.0 reply"} | |
| if doc.get("error"): | |
| err = doc["error"] if isinstance(doc["error"], dict) else {"message": str(doc["error"])} | |
| return None, str(err.get("message") or "error")[:500], rpc | |
| res = doc.get("result") | |
| tools = (res or {}).get("tools") if isinstance(res, dict) else None | |
| if not isinstance(tools, list): | |
| return None, "no tools", rpc | |
| keep = ("name", "title", "description", "inputSchema", "outputSchema", "annotations") | |
| return [{k: t.get(k) for k in keep if t.get(k) is not None} for t in tools[:120] if isinstance(t, dict) and t.get("name")], None, rpc | |
| def _api_routes(api: Any, openenv: bool) -> list[dict[str, Any]]: | |
| """The server's own routes from its OpenAPI, for the playground and the MCP bridge, on servers that aren't OpenEnv | |
| (NeMo Gym, ORS, custom): an OpenEnv server is used through reset/step and its tools, and its other routes are its | |
| own plumbing (geoguesser's /geoguesser/task/{i} returns the answer's coordinates). GET and POST only.""" | |
| if openenv or not isinstance(api, dict) or not isinstance(api.get("paths"), dict): | |
| return [] | |
| def resolve(x: Any, depth: int = 0) -> Any: | |
| if not isinstance(x, (dict, list)): | |
| return x | |
| if depth > 12: # deep or self-referencing schemas: cut, not looped | |
| return {} if isinstance(x, dict) else [] | |
| if isinstance(x, dict): | |
| if "$ref" in x and isinstance(x["$ref"], str): | |
| target = api | |
| if not x["$ref"].startswith("#/"): | |
| return {} | |
| for key in x["$ref"][2:].split("/"): | |
| target = target.get(key.replace("~1", "/").replace("~0", "~"), {}) if isinstance(target, dict) else {} | |
| return resolve(target, depth + 1) | |
| return {k: resolve(v, depth + 1) for k, v in x.items() if k != "$defs"} | |
| if isinstance(x, list): | |
| return [resolve(v, depth + 1) for v in x[:50]] | |
| return x | |
| out = [] | |
| for path, ops in api["paths"].items(): | |
| # only plain paths on the Space itself: never "//host/β¦", a scheme, "..", backslashes or a query | |
| if (not isinstance(ops, dict) or not isinstance(path, str) or not re.match(r"^/(?!/)[\w\-.~{}/]{0,199}$", path) | |
| or ".." in path): | |
| continue | |
| # A generic Gymnasium server's reset/step are ordinary HTTP routes, not | |
| # evidence that it implements OpenEnv's WebSocket envelope protocol. | |
| if path in STANDARD_ROUTES - {"/reset", "/step", "/state"} or path.startswith(("/web", "/login", "/logout", "/docs", "/redoc", "/static", "/gradio", "/assets")): | |
| continue | |
| if openenv and path in ("/healthz",): | |
| continue | |
| for method in ("post", "get"): | |
| op = ops.get(method) | |
| if not isinstance(op, dict): | |
| continue | |
| body = (((op.get("requestBody") or {}).get("content") or {}).get("application/json") or {}).get("schema") | |
| schema = resolve(body) if body else None | |
| parameters = {} | |
| for p in [*(ops.get("parameters") or []), *(op.get("parameters") or [])]: | |
| p = resolve(p) | |
| if isinstance(p, dict) and p.get("in") == "query" and isinstance(p.get("name"), str): | |
| parameters[p["name"]] = p | |
| query = {"type": "object", "properties": {k: {**(p.get("schema") or {}), "description": p.get("description") or "Query parameter"} | |
| for k, p in parameters.items()}, | |
| "required": [k for k, p in parameters.items() if p.get("required")], "additionalProperties": False} | |
| out.append({"method": method.upper(), "path": path, "summary": str(op.get("summary") or op.get("description") or "")[:200], | |
| "params": re.findall(r"\{([A-Za-z_][\w-]{0,40})\}", path), | |
| "schema": schema if isinstance(schema, dict) else None, | |
| "body_required": bool((op.get("requestBody") or {}).get("required")), "query_schema": query}) | |
| if len(out) >= 40: | |
| break | |
| return out | |
| def route_url(info: dict, method: str, path: str, params: dict[str, Any], query: dict[str, Any] | None = None) -> str: | |
| """A route the server published, its path parameters filled in safely; anything else is refused.""" | |
| route = next((r for r in info.get("api") or [] if r["method"] == method and r["path"] == path), None) | |
| if route is None: | |
| raise SpaceError("that route isn't one this server publishes", 400) | |
| filled = path | |
| for name in route["params"]: | |
| v = str((params or {}).get(name, "")) | |
| if not re.fullmatch(r"[\w.\-~]{1,200}", v) or v in (".", ".."): | |
| raise SpaceError(f"{name}: letters, digits, . - _ ~ only", 400) | |
| filled = filled.replace("{" + name + "}", v) | |
| query = query or {} | |
| schema = route.get("query_schema") or {} | |
| if set(query) - set(schema.get("properties") or {}): | |
| raise SpaceError("only this route's published query parameters are allowed", 400) | |
| for key in schema.get("required") or []: | |
| if key not in query: | |
| raise SpaceError(f"query parameter {key} is required", 400) | |
| pairs = [] | |
| for key, value in query.items(): | |
| for v in value if isinstance(value, list) else [value]: | |
| if v is None: | |
| continue | |
| if not isinstance(v, (str, int, float, bool)): | |
| raise SpaceError(f"query parameter {key} must be a scalar or array of scalars", 400) | |
| pairs.append((key, str(v).lower() if isinstance(v, bool) else str(v))) | |
| encoded = urlencode(pairs) | |
| if len(encoded) > 8000: | |
| raise SpaceError("query parameters are too large", 400) | |
| if encoded: | |
| filled += "?" + encoded | |
| return filled | |
| def probe(spec: str, fresh: bool = False) -> dict[str, Any]: | |
| """Everything a running server says about itself, in one record. Asleep or broken: just the stage.""" | |
| rec = record(spec, fresh=fresh) | |
| if rec["stage"] != UP or not rec["host"]: | |
| from .envs.capabilities import space_support | |
| info = {"running": False, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"], | |
| "last_seen": last_seen(rec["id"])} | |
| from . import space_checks | |
| space_checks.observe(spec, info) | |
| return {**info, "support": space_support(info)} | |
| def fetch(): | |
| with ThreadPoolExecutor(6) as pool: | |
| f_api = pool.submit(_get_json, rec, "/openapi.json") | |
| f_meta = pool.submit(_get_json, rec, "/metadata") | |
| f_schema = pool.submit(_get_json, rec, "/schema") | |
| f_health = pool.submit(_get_json, rec, "/health") | |
| f_envs = pool.submit(_get_json, rec, "/list_environments") | |
| f_ui = pool.submit(_ui_path, rec) | |
| api, meta, schema, health, envs, ui = (f.result() for f in (f_api, f_meta, f_schema, f_health, f_envs, f_ui)) | |
| routes = sorted((api or {}).get("paths", {}).keys()) if isinstance(api, dict) else [] | |
| has = set(routes) | |
| openenv = isinstance(schema, dict) and all(isinstance(schema.get(k), dict) for k in ("action", "observation", "state")) | |
| tools, tools_error, rpc = _tools({**rec, "mcp_standard": not openenv}) if ("/mcp" in has or openenv) else (None, None, None) | |
| env_names = [e for e in envs if isinstance(e, str) and re.match(r"^[\w.-]{1,80}$", e)] if isinstance(envs, list) else [] | |
| from . import space_tasks | |
| task_api = space_tasks.discover(rec, has, env_names) | |
| names = {t["name"] for t in tools or []} | |
| harbor = {"run_rollout", "capabilities"} <= names or "harbor_env" in env_names | |
| caps = _harbor_caps(rec) if "capabilities" in names and harbor else None | |
| obs = (schema or {}).get("observation") if isinstance(schema, dict) else None | |
| version = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None | |
| info = { | |
| "running": True, "stage": rec["stage"], "host": rec["host"], "base_path": rec["base_path"], | |
| "ui": ui, "openenv": openenv, | |
| "metadata": meta if isinstance(meta, dict) else None, "health": health if isinstance(health, dict) else None, | |
| "schema": schema if isinstance(schema, dict) else None, | |
| "step_api": openenv and "/reset" in has and "/step" in has, "docs": "/openapi.json" in has or api is not None, | |
| "mcp": tools, "mcp_error": None if tools else tools_error, | |
| "task_api": task_api, | |
| "harbor": harbor, "harbor_caps": caps, | |
| "rewards": bool(isinstance(obs, dict) and "reward" in (obs.get("properties") or {})), | |
| "graders": sorted(n for n in names if re.search(r"(grade|score|submit|verify|reward|judge)", n, re.I)), | |
| "routes": [r for r in routes if r not in STANDARD_ROUTES][:40], | |
| "api": _api_routes(api, openenv), | |
| # which framework the server speaks, from what it serves | |
| "framework": "harbor" if harbor else "openenv" if openenv else | |
| "nemo-gym" if {"/seed_session", "/verify"} <= has else | |
| "ors" if {"/create_session", "/{env_name}/call"} <= has else | |
| "gymnasium" if {"/reset", "/step"} <= has else "mcp" if tools else "api" if api else None, | |
| "openapi_version": version if isinstance(version, str) else None, | |
| "endpoint_methods": {p: {m: {} for m in methods if m in ("get", "post")} for p, methods in (api or {}).get("paths", {}).items() | |
| if p in ("/reset", "/step", "/state", "/mcp") and isinstance(methods, dict)} if isinstance(api, dict) else {}, | |
| # OpenEnv's two modes: simulation serves /reset, /step and /state; production serves only MCP and /ws | |
| "mode": ("simulation" if "/reset" in has else "production") if openenv else None, | |
| "conformance": conformance({"openapi": api, "health": health, "metadata": meta, "schema": schema, "mcp": rpc, | |
| "mcp_tried": "/mcp" in has or openenv, "paths": routes}), | |
| "checked": time.time(), | |
| } | |
| if api is not None or isinstance(schema, dict) or isinstance(meta, dict) or tools: # it answered: keep it | |
| remember(rec["id"], info) | |
| from .envs.capabilities import space_support | |
| info["support"] = space_support(info) | |
| from . import space_checks | |
| space_checks.observe(spec, info) | |
| return info | |
| if fresh: | |
| catalog._memo.pop(("space-probe", spec), None) | |
| return catalog._cached(("space-probe", spec), 300, fetch) | |
| # ββ `openenv validate`, from what the probe saw βββββββββββββββββββββββββββββ | |
| # The six runtime criteria of `openenv validate <url>` (openenv/cli/_validation.py), each judged from the probe's | |
| # own requests rather than by calling the Space again. | |
| CRITERIA = ( | |
| ("openapi_version_available", "GET /openapi.json returns OpenAPI info.version"), | |
| ("health_endpoint", "GET /health returns healthy status"), | |
| ("metadata_endpoint", "GET /metadata returns name and description"), | |
| ("schema_endpoint", "GET /schema returns action, observation, and state schemas"), | |
| ("mcp_endpoint", "POST /mcp is reachable and returns JSON-RPC payload"), | |
| ("mode_endpoint_consistency", "OpenAPI endpoint set matches OpenEnv mode contract"), | |
| ) | |
| def conformance(facts: dict | None) -> list[dict[str, str]]: | |
| """pass / fail / unknown, with a short reason, for each criterion. `facts`: what the probe got back (`openapi`, | |
| `health`, `metadata`, `schema`: parsed JSON or None; `mcp`: {ok, detail} or None; `mcp_tried`; `paths`).""" | |
| def row(i: int, status: str, reason: str) -> dict[str, str]: | |
| return {"id": CRITERIA[i][0], "label": CRITERIA[i][1], "status": status, "reason": reason[:200]} | |
| if not facts: | |
| return [row(i, "unknown", "not seen running") for i in range(len(CRITERIA))] | |
| out = [] | |
| api = facts.get("openapi") | |
| ver = (api.get("info") or {}).get("version") if isinstance(api, dict) and isinstance(api.get("info"), dict) else None | |
| out.append(row(0, "pass", f"version {ver}") if isinstance(ver, str) else | |
| row(0, "fail", "no info.version in its OpenAPI" if isinstance(api, dict) else "no JSON at /openapi.json")) | |
| h = facts.get("health") | |
| out.append(row(1, "pass", 'status "healthy"') if isinstance(h, dict) and h.get("status") == "healthy" else | |
| row(1, "fail", f'status {json.dumps(h.get("status"))[:60]}, not "healthy"' if isinstance(h, dict) else "no JSON at /health")) | |
| m = facts.get("metadata") | |
| missing = [k for k in ("name", "description") if not (isinstance(m, dict) and isinstance(m.get(k), str))] | |
| out.append(row(2, "pass", "name and description") if not missing else | |
| row(2, "fail", f"no {' or '.join(missing)}" if isinstance(m, dict) else "no JSON at /metadata")) | |
| sc = facts.get("schema") | |
| gone = [k for k in ("action", "observation", "state") if not (isinstance(sc, dict) and isinstance(sc.get(k), dict))] | |
| out.append(row(3, "pass", "action, observation and state") if not gone else | |
| row(3, "fail", f"no {', '.join(gone)} schema" if isinstance(sc, dict) else "no JSON at /schema")) | |
| rpc, paths = facts.get("mcp"), facts.get("paths") | |
| if isinstance(rpc, dict): | |
| out.append(row(4, "pass" if rpc.get("ok") else "fail", str(rpc.get("detail") or ""))) | |
| elif paths: | |
| out.append(row(4, "fail", "no /mcp among its routes")) | |
| else: | |
| out.append(row(4, "unknown", "not asked: no OpenAPI and no schema")) | |
| if paths: | |
| has = set(paths) | |
| if "/reset" in has: | |
| lack = [p for p in ("/step", "/state") if p not in has] | |
| out.append(row(5, "pass", "simulation: /reset, /step and /state") if not lack else row(5, "fail", f"simulation without {' and '.join(lack)}")) | |
| else: | |
| extra = [p for p in ("/step", "/state") if p in has] | |
| out.append(row(5, "pass", "production: no /reset, /step or /state") if not extra else row(5, "fail", f"production with {' and '.join(extra)}")) | |
| else: | |
| out.append(row(5, "fail", "no OpenAPI paths to tell its mode")) | |
| return out | |
| # ββ last seen: the last good probe of each Space, kept βββββββββββββββββββββββ | |
| SEEN_MAX = 512 * 1024 # bytes of one record; bigger tool and schema lists are trimmed to fit | |
| def _seen_path(spec: str): | |
| return config.STORAGE_DIR / "space-probes" / f"{catalog._slug(catalog.check_spec(spec))}.json" | |
| def _seen_record(spec: str, info: dict[str, Any]) -> dict[str, Any]: | |
| meta = info.get("metadata") if isinstance(info.get("metadata"), dict) else None | |
| out = { | |
| "spec": spec, "checked_at": info.get("checked") or time.time(), | |
| "framework": info.get("framework"), "openenv": info.get("openenv"), "step_api": info.get("step_api"), | |
| "mode": info.get("mode"), "ui": info.get("ui"), "base_path": info.get("base_path"), "harbor": info.get("harbor"), | |
| "rewards": info.get("rewards"), "openapi_version": info.get("openapi_version"), | |
| "metadata": {k: str(meta[k])[:2000] for k in ("name", "description", "version", "author", "documentation_url") if meta.get(k) is not None} if meta else None, | |
| "schema": info.get("schema"), "mcp": info.get("mcp"), "task_api": info.get("task_api"), | |
| "graders": info.get("graders"), "conformance": info.get("conformance"), | |
| } | |
| for trim in (None, "outputSchema", "inputSchema", "schema"): # what's dropped first when it's too big to keep | |
| if trim == "schema": | |
| out["schema"] = None | |
| elif trim: | |
| out["mcp"] = [{k: v for k, v in t.items() if k != trim} for t in out.get("mcp") or []] or out.get("mcp") | |
| if len(json.dumps(out, default=str)) <= SEEN_MAX: | |
| break | |
| return out | |
| def remember(spec: str, info: dict[str, Any]) -> None: | |
| """Keep this probe as the Space's last seen (best effort: a failed write never fails the probe).""" | |
| try: | |
| rec = _seen_record(spec, info) | |
| p = _seen_path(spec) | |
| old = last_seen(spec) | |
| same = old and {k: v for k, v in old.items() if k != "checked_at"} == json.loads(json.dumps({k: v for k, v in rec.items() if k != "checked_at"}, default=str)) | |
| if same and rec["checked_at"] - (old.get("checked_at") or 0) < 60: | |
| return | |
| catalog.atomic_write(p, json.dumps(rec, default=str, separators=(",", ":")).encode()) | |
| catalog._memo.pop(("space-seen", spec), None) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| def last_seen(spec: str) -> dict[str, Any] | None: | |
| """What the Space offered when it was last seen running here, with when (None if never).""" | |
| def read(): | |
| try: | |
| doc = json.loads(_seen_path(spec).read_bytes()) | |
| except (OSError, ValueError): | |
| return None | |
| return doc if isinstance(doc, dict) and doc.get("spec") == spec else None | |
| try: | |
| return catalog._cached(("space-seen", spec), 30, read) | |
| except Exception: # noqa: BLE001 | |
| return None | |
| def hub_dataset(name: str) -> str | None: | |
| """The Hub dataset an OpenEnv Γ Harbor server serves under a local name: `openenv harbor push` copies | |
| `org/name` to `/data/org__name`.""" | |
| parts = name.rstrip("/").rsplit("/", 1)[-1].split("__") | |
| if len(parts) != 2 or not all(re.match(r"^[A-Za-z0-9][\w.-]{0,95}$", p) for p in parts): | |
| return None # none, or more than one `__`: which one is the separator can't be told | |
| return f"{parts[0]}/{parts[1]}" | |
| def _harbor_caps(rec: dict) -> dict[str, Any] | None: | |
| """What an OpenEnv Γ Harbor server can run: its engine, sandboxes, harnesses, and the Hub datasets it serves.""" | |
| try: | |
| res = mcp_call(rec, "tools/call", {"name": "capabilities", "arguments": {}}, timeout=15) | |
| text = (res.get("content") or [{}])[0].get("text") if isinstance(res, dict) else res | |
| caps = json.loads(text) if isinstance(text, str) else text | |
| except (SpaceError, ValueError, TypeError, IndexError, AttributeError): | |
| return None | |
| if not isinstance(caps, dict): | |
| return None | |
| llm = caps.get("llm") if isinstance(caps.get("llm"), dict) else {} | |
| return { | |
| "engine": {"url": bool(llm.get("url")), "model": str(llm.get("model") or "")[:120], "ok": llm.get("ok")}, | |
| "sandboxes": [{"name": str(x.get("name")), "available": bool(x.get("available"))} for x in caps.get("sandboxes") or [] if isinstance(x, dict)][:12], | |
| "harnesses": [{"name": str(x.get("name")), "status": str(x.get("status") or "")} for x in caps.get("harnesses") or [] if isinstance(x, dict)][:60], | |
| "datasets": [{"name": str(x.get("name")), "num_tasks": x.get("num_tasks"), "hub": hub_dataset(str(x.get("name") or ""))} | |
| for x in caps.get("datasets") or [] if isinstance(x, dict)][:20], | |
| } | |
| # ββ waking and restarting ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _woken: dict[str, float] = {} | |
| def wake(spec: str) -> dict[str, Any]: | |
| """Wake a sleeping Space the way its Hub page does for any visitor ("Restart this Space"). At most once a minute.""" | |
| rec = record(spec, fresh=True) | |
| if rec["stage"] in STARTING or rec["stage"] == UP: | |
| return {"stage": rec["stage"], "woken": False} | |
| if rec["stage"] != "SLEEPING": | |
| raise SpaceError(f"this Space is {rec['stage'].lower().replace('_', ' ')}: only its owner can restart it", 409) | |
| with _lock: | |
| if time.time() - _woken.get(rec["id"], 0) < 60: | |
| return {"stage": "APP_STARTING", "woken": True} | |
| _woken[rec["id"]] = time.time() | |
| try: | |
| r = httpx.post(f"https://huggingface.co/spaces/{rec['id']}/start", data={"csrf": ""}, timeout=15, follow_redirects=False) | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the Hub: {type(e).__name__}", 502) from None | |
| if r.status_code not in (200, 302, 303, 409): | |
| raise SpaceError(f"the Hub refused to wake it (HTTP {r.status_code})", 502) | |
| return {"stage": record(spec, fresh=True)["stage"], "woken": True} | |
| def restart(spec: str, token: str) -> dict[str, Any]: | |
| """Restart a Space with the visitor's own token: works when they may write to it (their Space, or their org's).""" | |
| from huggingface_hub.errors import HfHubHTTPError | |
| rec = record(spec, fresh=True) | |
| try: | |
| catalog._api(token).restart_space(rec["id"]) | |
| except HfHubHTTPError as e: | |
| code = getattr(e.response, "status_code", 0) | |
| if code in (401, 403): | |
| raise SpaceError("you can't restart this Space: it takes write access to it (its owner, or a member of its " | |
| "organization, signed in with a token that may write)", 403) from None | |
| raise SpaceError(f"the Hub refused the restart (HTTP {code})", 502) from None | |
| return {"stage": record(spec, fresh=True)["stage"]} | |
| # ββ the Task API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _withhold(v: Any, depth: int = 0) -> tuple[Any, list[str]]: | |
| """A task with answer-like fields left out (recursively), and the names it left out.""" | |
| gone: list[str] = [] | |
| if depth > 6: | |
| return v, gone | |
| if isinstance(v, dict): | |
| out = {} | |
| for k, x in v.items(): | |
| if isinstance(k, str) and ANSWER.search(k): | |
| gone.append(k) | |
| continue | |
| out[k], g = _withhold(x, depth + 1) | |
| gone += g | |
| return out, gone | |
| if isinstance(v, list): | |
| out = [] | |
| for x in v[:500]: | |
| y, g = _withhold(x, depth + 1) | |
| out.append(y) | |
| gone += g | |
| return out, gone | |
| return v, gone | |
| def _task_api(spec: str, env: str = "") -> tuple[dict, str]: | |
| info = probe(spec) | |
| if not info.get("running"): | |
| raise SpaceError("this Space isn't running: wake it first", 409) | |
| if not info.get("task_api"): | |
| raise SpaceError("this server has no Task API", 404) | |
| api = info["task_api"] | |
| if env and env not in [e["env"] for e in api.get("environments", [api])]: | |
| raise SpaceError("this environment is not in the Space's Task API", 400) | |
| return record(spec), env or api["env"] | |
| def tasks(spec: str, split: str, start: int, stop: int, env: str = "") -> dict[str, Any]: | |
| rec, env = _task_api(spec, env) | |
| start, stop = max(0, int(start)), max(0, int(stop)) | |
| stop = min(stop, start + 50) | |
| try: | |
| r = _client.post(_url(rec, f"/{env}/task_range"), json={"split": split, "start": start, "stop": stop}, timeout=20) | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the Space: {type(e).__name__}", 502) from None | |
| if r.status_code != 200: | |
| raise SpaceError(f"the Task API answered HTTP {r.status_code}", 502) | |
| rows = (_json(r) or {}).get("tasks") or [] | |
| rows, gone = _withhold(rows if isinstance(rows, list) else []) | |
| return {"tasks": rows, "start": start, "stop": stop, "withheld": sorted(set(gone))} | |
| def task(spec: str, split: str, index: int, env: str = "") -> dict[str, Any]: | |
| rec, env = _task_api(spec, env) | |
| try: | |
| r = _client.post(_url(rec, f"/{env}/task"), json={"split": split, "index": int(index)}, timeout=20) | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the Space: {type(e).__name__}", 502) from None | |
| if r.status_code != 200: | |
| raise SpaceError(f"the Task API answered HTTP {r.status_code}", 404 if r.status_code in (400, 404) else 502) | |
| t, gone = _withhold((_json(r) or {}).get("task")) | |
| return {"task": t, "withheld": sorted(set(gone))} | |
| # ββ playground sessions ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # One live episode per session, over one WebSocket to the server's /ws: it carries reset/step/state and MCP calls | |
| # ({"type": "mcp"}) on the same environment instance (the HTTP /reset, /step and /mcp are stateless). A server whose | |
| # socket won't open still gets its MCP tools, one stateless HTTP call at a time. Sessions belong to whoever started | |
| # them (the page's address and account), end after 10 idle minutes or an hour, and are capped per owner and in total. | |
| IDLE, LIFE, PER_OWNER, TOTAL = 600, 3600, 3, 80 | |
| PER_AGENT_HOST = 8 # MCP clients: several coding agents on one machine each keep their own episode | |
| class Session: | |
| def __init__(self, spec: str, owner: str): | |
| self.id = secrets.token_urlsafe(18) | |
| self.spec, self.owner = spec, owner | |
| self.created = self.used = time.time() | |
| self.ws = None | |
| self.ws_failed = False | |
| self.broken = False # a dropped episode may only be recovered by an explicit reset | |
| self.http: httpx.Client | None = None # the session's own cookie jar for the server's routes (NeMo Gym keeps state in a cookie) | |
| self.remote_mcp: RemoteMCP | None = None | |
| self.standard_mcp = False | |
| self.lock = threading.Lock() | |
| self.steps = 0 | |
| self.rpc = 0 | |
| def close(self) -> None: | |
| if self.remote_mcp is not None: | |
| self.remote_mcp.close() | |
| self.remote_mcp = None | |
| if self.http is not None: | |
| try: | |
| self.http.close() | |
| except Exception: # noqa: BLE001 | |
| pass | |
| self.http = None | |
| ws, self.ws = self.ws, None | |
| if ws is None: | |
| return | |
| try: | |
| ws.send(json.dumps({"type": "close"})) | |
| except Exception: # noqa: BLE001 - closing is best effort | |
| pass | |
| try: | |
| ws.close() | |
| except Exception: # noqa: BLE001 | |
| pass | |
| _sessions: dict[str, Session] = {} | |
| def _sweep() -> None: | |
| now = time.time() | |
| with _lock: | |
| dead = [s for s in _sessions.values() if now - s.used > IDLE or now - s.created > LIFE] | |
| for s in dead: | |
| _sessions.pop(s.id, None) | |
| for s in dead: | |
| s.close() | |
| def start(spec: str, owner: str) -> dict[str, Any]: | |
| info = probe(spec) | |
| if not info.get("running"): | |
| raise SpaceError("this Space isn't running: wake it first", 409) | |
| if not (info.get("openenv") or info.get("mcp") or info.get("api")): | |
| raise SpaceError("this Space has no API to play with", 404) | |
| _sweep() | |
| cap = PER_AGENT_HOST if owner.startswith("mcp:") else PER_OWNER | |
| with _lock: | |
| mine = [s for s in _sessions.values() if s.owner == owner] | |
| if len(mine) >= cap: # the oldest of yours makes room | |
| oldest = min(mine, key=lambda s: s.used) | |
| _sessions.pop(oldest.id, None) | |
| threading.Thread(target=oldest.close, daemon=True).start() | |
| if len(_sessions) >= TOTAL: | |
| raise SpaceError("too many live sessions on this explorer right now: try again in a few minutes", 429) | |
| s = Session(catalog.check_spec(spec), owner) | |
| s.ws_failed = not bool(info.get("openenv")) | |
| s.standard_mcp = not bool(info.get("openenv")) | |
| _sessions[s.id] = s | |
| return {"session": s.id} | |
| def _get(sid: str, owner: str) -> Session: | |
| _sweep() | |
| s = _sessions.get(sid) | |
| if s is None or s.owner != owner: | |
| raise SpaceError("this session has ended: start a new one", 410) | |
| s.used = time.time() | |
| return s | |
| def end(sid: str, owner: str) -> None: | |
| s = _sessions.get(sid) | |
| if s is not None and s.owner == owner: | |
| _sessions.pop(sid, None) | |
| s.close() | |
| def _open(s: Session) -> None: | |
| from websockets.sync.client import connect | |
| rec = record(s.spec) | |
| if not rec["host"]: | |
| raise SpaceError("this Space has no app address", 404) | |
| try: # the sync client never follows redirects, so this only ever reaches the Space's own host | |
| s.ws = connect("wss://" + rec["host"][len("https://"):] + "/ws", open_timeout=20, max_size=MAX_REPLY, close_timeout=3, | |
| user_agent_header="hf-rl-explorer (+https://huggingface.co/FineEnvs)") | |
| except Exception as e: # noqa: BLE001 | |
| s.ws_failed = True | |
| raise SpaceError(f"couldn't open a session on the Space ({type(e).__name__})", 502) from None | |
| def _ws_send(s: Session, msg: dict, timeout: float) -> dict: | |
| if s.broken and msg.get("type") != "reset": | |
| raise SpaceError("the episode was disconnected: reset to start a new episode", 410) | |
| if msg.get("type") == "reset": | |
| s.broken = False | |
| if s.ws is None: | |
| _open(s) | |
| try: | |
| s.ws.send(json.dumps(msg)) | |
| reply = json.loads(s.ws.recv(timeout=timeout)) | |
| except TimeoutError: | |
| s.close() | |
| s.broken = True | |
| raise SpaceError(f"the Space didn't answer within {int(timeout)} s; the session was closed", 504) from None | |
| except Exception as e: # noqa: BLE001 - the socket dropped: the episode is gone | |
| s.close() | |
| s.broken = True | |
| raise SpaceError(f"the session dropped ({type(e).__name__}): start a new episode", 502) from None | |
| if not isinstance(reply, dict): | |
| raise SpaceError("the Space answered something unexpected", 502) | |
| if reply.get("type") == "error": | |
| data = reply.get("data") or {} | |
| raise SpaceError(str(data.get("message") or data.get("detail") or data)[:600] if isinstance(data, dict) else str(data)[:600], 422) | |
| return reply.get("data") or {} | |
| def _rpc(s: Session, method: str, params: dict, timeout: float) -> Any: | |
| """Use OpenEnv's episode socket, standard HTTP MCP, or legacy OpenEnv HTTP fallback.""" | |
| if not s.ws_failed: | |
| try: | |
| s.rpc += 1 | |
| data = _ws_send(s, {"type": "mcp", "data": {"jsonrpc": "2.0", "id": s.rpc, "method": method, "params": params}}, timeout) | |
| except SpaceError as e: | |
| if "capacity" in str(e).lower(): # its one session is someone else's: tools still work, one call at a time | |
| s.close() | |
| s.ws_failed = True | |
| elif not s.ws_failed: | |
| raise | |
| else: | |
| if isinstance(data, dict) and data.get("error"): | |
| err = data["error"] if isinstance(data["error"], dict) else {"message": str(data["error"])} | |
| raise SpaceError(str(err.get("message") or "error")[:600], 422) | |
| return data.get("result") if isinstance(data, dict) else data | |
| if s.standard_mcp: | |
| if s.http is None: | |
| s.http = SpaceClient(timeout=httpx.Timeout(120, connect=8), follow_redirects=False) | |
| if s.remote_mcp is None: | |
| s.remote_mcp = RemoteMCP(record(s.spec), s.http) | |
| return s.remote_mcp.call(method, params, timeout) | |
| return mcp_call(record(s.spec), method, params, timeout=timeout) | |
| def _http(s: Session, data: dict) -> dict[str, Any]: | |
| """One call to a route the server published, with the session's cookies; its status and body (JSON, text, image).""" | |
| method = str(data.get("method") or "").upper() | |
| if method not in ("GET", "POST"): | |
| raise SpaceError("GET or POST only", 400) | |
| info = probe(s.spec) | |
| path = route_url(info, method, str(data.get("path") or ""), data.get("params") if isinstance(data.get("params"), dict) else {}, | |
| data.get("query") if isinstance(data.get("query"), dict) else {}) | |
| rec = record(s.spec) | |
| if s.http is None: | |
| s.http = SpaceClient(base_url=rec["host"], timeout=httpx.Timeout(120, connect=8), follow_redirects=False, | |
| headers={"User-Agent": "hf-rl-explorer (+https://huggingface.co/FineEnvs)"}) | |
| body = data.get("body") | |
| try: | |
| r = s.http.request(method, _url(rec, path), json=body if method == "POST" else None) | |
| except httpx.TimeoutException: | |
| raise SpaceError("the Space didn't answer within 120 s", 504) from None | |
| except httpx.HTTPError as e: | |
| raise SpaceError(f"couldn't reach the Space: {type(e).__name__}", 502) from None | |
| if len(r.content) > MAX_REPLY: | |
| raise SpaceError("the reply is too large to show", 502) | |
| ct = r.headers.get("content-type", "") | |
| out: dict[str, Any] = {"status": r.status_code, "content_type": ct.split(";")[0]} | |
| if "json" in ct: | |
| try: | |
| out["json"] = r.json() | |
| except ValueError: | |
| out["text"] = r.text[:200_000] | |
| else: | |
| if method == "GET": # browsing what it serves: answer-like fields stay out, as in the Task API browser | |
| out["json"], gone = _withhold(out["json"]) | |
| if gone: | |
| out["withheld"] = sorted(set(gone)) | |
| elif ct.startswith("image/") and ct.split(";")[0] in ("image/png", "image/jpeg", "image/gif", "image/webp"): | |
| out["image"] = f"data:{ct.split(';')[0]};base64,{base64.b64encode(r.content).decode()}" | |
| else: | |
| out["text"] = r.text[:200_000] | |
| return out | |
| def act(sid: str, owner: str, op: str, data: dict | None = None) -> dict[str, Any]: | |
| """reset / step / state, or call / tools (MCP), inside the session's episode.""" | |
| s = _get(sid, owner) | |
| data = data if isinstance(data, dict) else {} | |
| with s.lock: | |
| t0 = time.time() | |
| if op == "reset": # a new episode: safe to try again once on a fresh socket if the first one drops | |
| try: | |
| out = _ws_send(s, {"type": "reset", "data": data}, timeout=120) | |
| except SpaceError as e: | |
| if e.status != 502: | |
| raise | |
| s.close() | |
| s.ws_failed = False | |
| out = _ws_send(s, {"type": "reset", "data": data}, timeout=120) | |
| s.steps = 0 | |
| elif op == "step": | |
| out = _ws_send(s, {"type": "step", "data": data}, timeout=180) | |
| s.steps += 1 | |
| elif op == "state": | |
| out = _ws_send(s, {"type": "state"}, timeout=30) | |
| elif op == "call": | |
| name = data.get("name") | |
| if not isinstance(name, str) or not re.match(r"^[\w.\-/]{1,120}$", name): | |
| raise SpaceError("which tool?", 400) | |
| args = data.get("arguments") if isinstance(data.get("arguments"), dict) else {} | |
| out = _rpc(s, "tools/call", {"name": name, "arguments": args}, timeout=900 if name == "run_rollout" else 180) | |
| s.steps += 1 | |
| elif op == "tools": | |
| out = _rpc(s, "tools/list", {}, timeout=20) | |
| elif op == "http": | |
| out = _http(s, data) | |
| s.steps += 1 | |
| else: | |
| raise SpaceError(f"unknown operation {op!r}", 400) | |
| return {"result": out, "ms": int((time.time() - t0) * 1000), "steps": s.steps, | |
| "stateful": op == "http" or s.standard_mcp or not s.ws_failed} | |
| def live_sessions() -> int: | |
| _sweep() | |
| return len(_sessions) | |
| def image_parts(obs: Any) -> list[tuple[str, str]]: | |
| """(mime type, base64) for every image a reply carries as base64: for MCP clients, which can show them.""" | |
| found: list[tuple[str, str]] = [] | |
| def walk(v: Any, key: str = "", depth: int = 0) -> None: | |
| if depth > 5 or len(found) >= 4: | |
| return | |
| if isinstance(v, dict): | |
| fmt = str(v.get("image_format") or v.get("format") or "png").lower() | |
| for k, x in v.items(): | |
| if isinstance(x, str) and len(x) > 200 and re.search(r"(image|img|png|jpe?g|frame|screenshot|pixels)", k, re.I): | |
| if x.startswith("data:image/"): | |
| head, _, b64 = x.partition(",") | |
| found.append((head[5:].split(";")[0], b64)) | |
| elif re.match(r"^[A-Za-z0-9+/=\s]+$", x[:400]): | |
| try: | |
| base64.b64decode(x[:64] + "=" * (-len(x[:64]) % 4)) | |
| found.append((f"image/{'jpeg' if fmt in ('jpg', 'jpeg') else fmt if fmt in ('png', 'webp', 'gif') else 'png'}", x)) | |
| except Exception: # noqa: BLE001 | |
| pass | |
| else: | |
| walk(x, k, depth + 1) | |
| elif isinstance(v, list): | |
| for x in v[:50]: | |
| walk(x, key, depth + 1) | |
| walk(obs) | |
| return found | |