RL-Explorer / app /mimo /runner /core.py
AdithyaSK's picture
AdithyaSK HF Staff
Deploy HF RL Explorer
6abb7be verified
Raw History Blame Contribute Delete
18.1 kB
"""One rollout, start to finish: sandbox up, environment set, agent runs, task's own verifier grades.
Everything a rollout does becomes an *event*, kept in memory for live viewers and appended to
`events.jsonl` in batches. Event kinds the UI understands:
phase {name, status: "start"|"done"|"error", detail}
text {text} an assistant message
tool {tool, title, input, output, status, ms}
step {tokens: {...}, cost} one model call finished
log {text} setup / verifier output worth showing
checks {checks: [{id, passed, score, method, tier, weight, message}], reward, summary}
error {text}
The user's token is held here for the rollout's lifetime only. It never reaches an event or a file.
"""
from __future__ import annotations
import json
import re
import secrets
import threading
import time
import traceback
import uuid
from concurrent.futures import ThreadPoolExecutor
from .. import catalog, config, models, store, version
_pool = ThreadPoolExecutor(max_workers=config.MAX_ACTIVE_ROLLOUTS, thread_name_prefix="rollout")
_live: dict[str, "Rollout"] = {}
_caps: dict[str, "Rollout"] = {} # model-proxy capability -> its rollout, while it runs
_live_lock = threading.Lock()
# key-shaped: a known prefix, then a run with an uppercase letter or a digit (code names like hf_raise_for_status don't match)
SECRET_RE = re.compile(r"\b(hf_|sk-|sk_|api_key=|Bearer )(?=[A-Za-z0-9\-]*[A-Z0-9])[A-Za-z0-9\-]{4,}(?![a-z_])")
ACTIVE_STATES = {"queued", "starting", "setup", "running", "verifying"}
class Cancelled(Exception):
pass
class Rollout:
def __init__(self, run: dict, token: str, agent_key: str | None = None):
self.run = run
self.id = run["id"]
self.token = token
self.agent_key = agent_key # a bring-your-own endpoint's key: memory only, like the HF token
# what the sandbox gets instead of credentials (see config.PUBLIC_URL): valid while this rollout is live
self.cap = secrets.token_urlsafe(32)
self.stream: dict | None = None # the model reply streaming through the proxy right now, counted as it goes
self.events: list[dict] = store.read_events(self.id)
self._pending: list[dict] = []
self._last_flush = time.time()
self._lock = threading.Lock()
self.t0 = time.time()
self.sandbox = None
self.cancelled = threading.Event()
self.tokens = {"input": 0, "output": 0, "reasoning": 0, "cache_read": 0, "cache_write": 0}
self.model_cost = 0.0
# ── events ───────────────────────────────────────────────────────────────
def _redact(self, v):
"""Nothing secret reaches a trace: the user's HF token, an endpoint key, or anything shaped like one
(the General verifier, for one, prints the first characters of its judge key)."""
if isinstance(v, str):
for secret in (self.token, self.agent_key, getattr(self, "cap", None)):
if secret:
v = v.replace(secret, "[redacted]")
return SECRET_RE.sub(lambda m: m.group(1) + "[redacted]", v)
if isinstance(v, dict):
return {k: self._redact(x) for k, x in v.items()}
if isinstance(v, list):
return [self._redact(x) for x in v]
return v
def emit(self, kind: str, **data) -> None:
ev = {"i": len(self.events), "t": round(time.time() - self.t0, 2), "kind": kind, **self._redact(data)}
with self._lock:
self.events.append(ev)
self._pending.append(ev)
if time.time() - self._last_flush > 1.0 or kind in ("phase", "checks", "error", "prompt"):
self._flush()
def _flush(self) -> None:
pending, self._pending = self._pending, []
self._last_flush = time.time()
store.append_events(self.id, pending)
def phase(self, name: str, status: str = "start", detail: str = "") -> None:
self.emit("phase", name=name, status=status, detail=detail)
if status == "start":
self.update(status={"sandbox": "starting", "setup": "setup", "agent": "running",
"verify": "verifying"}.get(name, self.run.get("status")), phase=name)
def log(self, text: str) -> None:
if text and text.strip():
self.emit("log", text=text.strip()[-6000:])
def add_tokens(self, t: dict) -> None:
for k in self.tokens:
self.tokens[k] += int(t.get(k) or 0)
ep = self.run.get("endpoint")
if ep: # your own endpoint: metered only if you told us its prices
t = self.tokens
self.model_cost = ((t["input"] + t["cache_read"] + t["cache_write"]) * (ep.get("price_in") or 0)
+ (t["output"] + t["reasoning"]) * (ep.get("price_out") or 0)) / 1e6
else:
self.model_cost = models.cost(self.run["model"], self.run.get("provider"), self.tokens)
self.update(tokens=self.tokens, cost=self.cost_now())
def cost_now(self) -> dict:
hours = (time.time() - self.t0) / 3600
sandbox = hours * config.FLAVOR_PRICE_PER_HOUR.get(self.run.get("flavor") or "", 0.0)
judge = self.run.get("judge_cost") or 0.0
return {"model": round(self.model_cost, 5), "judge": round(judge, 5), "sandbox": round(sandbox, 5),
"total": round(self.model_cost + judge + sandbox, 5)}
def update(self, **fields) -> None:
fields = self._redact(fields)
self.run.update(fields)
store.update(self.id, **fields)
def check_cancel(self) -> None:
if self.cancelled.is_set():
raise Cancelled()
# ── lifecycle ────────────────────────────────────────────────────────────
def start_sandbox(self, image: str, flavor: str) -> "object":
from huggingface_hub import Sandbox
self.phase("sandbox", detail=f"{image.rsplit(':', 1)[-1]} on {flavor}")
self.update(flavor=flavor)
t = time.time()
self.sandbox = Sandbox.create(image=image, flavor=flavor, idle_timeout=config.SANDBOX_IDLE_TIMEOUT,
forward_hf_token=False, token=self.token, start_timeout=900,
labels={"app": "rl-explorer", "runner": "mimo", "run": self.id})
self.update(sandbox_id=self.sandbox.id)
self.phase("sandbox", "done", f"ready in {time.time() - t:.0f}s")
return self.sandbox
def agent_api(self) -> tuple[str, str | None, str]:
"""(base URL, key, model string) the agent's model calls go to."""
ep = self.run.get("endpoint")
if ep:
return ep["base_url"], self.agent_key, self.run["model"]
mid = f"{self.run['model']}:{self.run['provider']}" if self.run.get("provider") else self.run["model"]
return config.ROUTER, self.token, mid
def llm_base(self) -> str | None:
"""Only a per-run capability reaches the sandbox, including local previews."""
if config.PUBLIC_URL:
return f"{config.PUBLIC_URL}/api/llm/{self.cap}/v1"
from ...runner import _proxy_url
return f"{_proxy_url().rstrip('/')}/rlx/llm/{self.cap}/v1"
def check_model_network(self):
"""Check the exact relay path from the sandbox, without a model call."""
url = self.llm_base().removesuffix("/v1") + "/health"
script = ("import urllib.request,urllib.error\n"
f"url={url!r}\n"
"try:\n"
" r=urllib.request.urlopen(url,timeout=15)\n"
" print('relay HTTP',r.status)\n"
"except urllib.error.HTTPError as e:\n"
" print('relay HTTP',e.code);raise SystemExit(1)\n"
"except Exception as e:\n"
" print(type(e).__name__);raise SystemExit(1)\n")
import shlex
result = self.sh("python3 -c " + shlex.quote(script), timeout=25)
if result.exit_code:
raise RuntimeError("The sandbox cannot reach the model relay. Check HTTPS egress from the sandbox and ingress to the explorer. "
"No account token was forwarded. " + (result.stdout or "")[-120:])
self.log("Network check passed: sandbox β†’ model relay. HF account token stays on the explorer.")
def upstream(self, model: str) -> tuple[str, str | None] | None:
"""For the model proxy: (base URL, key) for a model this rollout may call, else None."""
base, key, mid = self.agent_api()
if model == mid:
return base, key
if self.run.get("judge") and model == self.run["judge"]:
return config.ROUTER, self.token
return None
def sh(self, cmd: str, timeout: float = 600, **kw):
"""Run a shell command in the sandbox; never raises on a non-zero exit."""
self.check_cancel()
return self.sandbox.run(cmd, shell=True, check=False, timeout=timeout, **kw)
def settle_model(self) -> None:
"""Check, with tiny requests, that the model takes this rollout's settings, and adjust rather than fail.
Some models take no thinking level at all (Kimi-K2-Instruct answers HTTP 400 to reasoning_effort=low): run
those with thinking off. A provider that caps replies below the agent's output limit gets its own cap."""
p = self.run.get("params") or {}
base, key, mid = self.agent_api()
byo = bool(self.run.get("endpoint"))
eff = p.get("thinking")
if eff in ("low", "medium", "high") and _refused(base, key, mid, byo, {"reasoning_effort": eff}, r"reason|think|effort"):
p = {**p, "thinking": "none", "thinking_requested": eff}
self.update(params=p)
self.log(f"{self.run['model']} doesn't take a thinking level (it refused reasoning_effort={eff}), "
"so this rollout runs with thinking off.")
if self.run["domain"] != "music" and not p.get("max_tokens"):
from .opencode import OUTPUT_LIMIT
if _refused(base, key, mid, byo, {"max_tokens": OUTPUT_LIMIT}, r"token|length|context|max"):
self.update(params={**p, "output_limit": 32000})
self.log(f"The provider doesn't allow {OUTPUT_LIMIT:,}-token replies for this model; "
"the agent's replies are capped at 32,000 tokens.")
def execute(self) -> None:
from .domains import ADAPTERS
if self.run.get("runner") == "nemo-gym":
from ... import nemo_runner as adapter
else:
adapter = ADAPTERS[self.run["domain"]]
try:
self.update(status="starting", started_at=time.time())
self.settle_model()
result = adapter.run(self)
self.update(status="done", phase="done", reward=result.get("reward"), reward_error=result.get("error"),
graded=result.get("reward") is not None, wall_s=round(time.time() - self.t0, 2),
finished_at=time.time(), cost=self.cost_now())
self.phase("done", "done", f"reward {result.get('reward')}" if result.get("reward") is not None
else f"not scored: {result.get('error')}")
except Cancelled:
self.update(status="cancelled", finished_at=time.time(), cost=self.cost_now())
self.phase("done", "error", "cancelled")
except Exception as e: # noqa: BLE001
if self.cancelled.is_set(): # killing the sandbox makes the blocked call fail: that is the cancel, not a crash
self.update(status="cancelled", finished_at=time.time(), cost=self.cost_now())
else:
msg = friendly(e) or f"{type(e).__name__}: {str(e)[:300]}"
self.emit("error", text=msg, trace=traceback.format_exc()[-3000:])
self.update(status="failed", error=msg, finished_at=time.time(), cost=self.cost_now())
finally:
with self._lock:
self._flush()
self.stop_sandbox()
self.agent_key = None
self.native_task = None
with _live_lock:
_caps.pop(self.cap, None)
with _live_lock:
_live.pop(self.id, None)
self.token = ""
def stop_sandbox(self) -> None:
if self.sandbox is None:
return
try:
from huggingface_hub import Sandbox
Sandbox.kill(self.sandbox.id, token=self.token)
except Exception: # it idles out on its own anyway
pass
self.sandbox = None
def friendly(e: Exception) -> str | None:
"""The Hub errors people actually hit, said so they know what to do."""
status = getattr(getattr(e, "response", None), "status_code", None)
text = str(e)
if status == 402 or "402 Payment Required" in text:
return ("Your Hugging Face account has no prepaid credit for sandboxes, so none could start. Add credit at "
"https://huggingface.co/settings/billing and run again. Nothing was charged.")
if status in (401, 403) and "/api/jobs" in text:
return ("Your sign-in isn't allowed to start sandboxes. Sign in again and allow Jobs, or use a write token.")
if status == 429:
return "Hugging Face is rate-limiting sandbox starts for your account. Wait a minute and run again."
return None
# ── public API ───────────────────────────────────────────────────────────────
def active_for(user: str) -> int:
with _live_lock:
return sum(1 for r in _live.values() if r.run["user"] == user and not r.cancelled.is_set())
def submit(user: str, token: str, task: dict, model: str, provider: str | None, judge: str | None,
endpoint: dict | None = None, agent_key: str | None = None, params: dict | None = None,
visibility: str = "public", extra: dict | None = None, reserved: bool = False) -> dict:
"""`extra`: fields the explorer adds to the record (its dataset, ref, environment and runner). `reserved`: the
explorer already holds this rollout's place (app/runner._slot, with the admin's limits), so no check of its own."""
if not reserved and active_for(user) >= config.MAX_ACTIVE_PER_USER:
raise RuntimeError(f"You already have {config.MAX_ACTIVE_PER_USER} rollouts running. Wait for one to finish.")
with _live_lock:
if not reserved and len(_live) >= config.MAX_ACTIVE_ROLLOUTS:
raise RuntimeError("The explorer is at capacity right now. Try again in a few minutes.")
run = store.create({
"id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"user": user, "task_id": task["id"], "domain": task["domain"], "title": task["title"],
"facets": task.get("facets"), "model": model, "provider": provider, "judge": judge,
"status": "queued", "harness": "opencode", "endpoint": endpoint, "params": params or {},
"visibility": "private" if visibility == "private" else "public",
"image": catalog.image_for(task["id"]) if task["domain"] not in ("music", "nemo-gym") else None,
"provenance": version.provenance(),
**(extra or {}),
})
r = Rollout(run, token, agent_key)
r.native_task = task.get("native_task") # reference action stays in memory; never in the run record
with _live_lock:
_live[r.id] = r
_caps[r.cap] = r
_pool.submit(r.execute)
return run
_refusals: dict[tuple, bool] = {}
def _refused(base: str, key: str | None, mid: str, byo: bool, extra: dict, about: str) -> bool:
"""Does the model answer HTTP 400/422, about `about`, to a two-word request carrying `extra`? Cached."""
k = (base, mid, json.dumps(extra, sort_keys=True))
if k not in _refusals:
import httpx
from .. import endpoints
body = {"model": mid, "max_tokens": 16, **extra, "messages": [{"role": "user", "content": "Say OK."}]}
try:
if byo: # the user's endpoint: pinned connection, capped reply
r = endpoints.request("POST", f"{base}/chat/completions", key, timeout=60, json=body)
else:
r = httpx.post(f"{base}/chat/completions", json=body, timeout=60,
headers={"Authorization": f"Bearer {key}"} if key else {})
except Exception:
return False # can't tell; let the rollout itself report a real connection problem
refused = r.status_code in (400, 422) and bool(re.search(about, r.text, re.I))
if r.status_code in (200, 400, 422): # only cache real answers, not rate limits or outages
_refusals[k] = refused
return refused
return _refusals[k]
def by_cap(cap: str) -> Rollout | None:
with _live_lock:
r = _caps.get(cap)
return r if r is not None and not r.cancelled.is_set() else None
def live(run_id: str) -> Rollout | None:
with _live_lock:
return _live.get(run_id)
def events(run_id: str, after: int = 0) -> list[dict]:
r = live(run_id)
if r is not None:
with r._lock:
return r.events[after:]
return store.read_events(run_id, after)
def cancel(run_id: str) -> bool:
r = live(run_id)
if r is None:
return False
r.cancelled.set()
with _live_lock:
_caps.pop(r.cap, None)
# Say so at once: the worker may sit in a blocking sandbox call for a while after the kill.
r.update(status="cancelled", finished_at=time.time(), cost=r.cost_now())
r.phase("done", "error", "stopped by you")
threading.Thread(target=r.stop_sandbox, daemon=True).start()
return True