Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
3966c31
wip: save robot agent/recorder/bridge state before vectorized taskset…
lukass16 Jul 1, 2026
ebd1cbe
refactor(robot): serve every sim with one process shape (SimThread)
lukass16 Jul 6, 2026
0321435
refactor(telemetry): extract shared robot recording into hud.telemetr…
lukass16 Jul 6, 2026
ed4cb29
refactor(robot): batched-first RobotBridge with slots grading; add Gy…
lukass16 Jul 6, 2026
37826e4
feat(robot): declarative gym envs (env.gym) and hud.wrap trace streaming
lukass16 Jul 6, 2026
0f84f9c
refactor(agents): unify RobotAgent to drive N>=1 env slots
lukass16 Jul 6, 2026
8b27cae
feat(eval): grouped rollouts via rollout_group and Taskset.run(num_en…
lukass16 Jul 6, 2026
9e222ff
docs(robots): update for the unified robot stack
lukass16 Jul 6, 2026
47264de
feat(agents): size BatchedModel to the live concurrency by default
lukass16 Jul 6, 2026
4d05aae
refactor(eval): rename rollout_group to vec_rollout
lukass16 Jul 6, 2026
d4f7efa
fix(agents): satisfy strict pyright in the robot agent stack
lukass16 Jul 6, 2026
2fe6bfd
fix(agents): make DatasetWriter work with derived contracts
lukass16 Jul 6, 2026
923e546
fix(agents): stop recording inference for finished env slots
lukass16 Jul 6, 2026
7710838
docs(robots): run parameters table; batching and vec_rollout updates
lukass16 Jul 6, 2026
a622ba5
feat(environment): serve concurrent control sessions
jdchawla29 Jul 18, 2026
e353e12
merge: bring in concurrent control sessions (a622ba5)
lukass16 Jul 18, 2026
0faf857
refactor(agents): drop per-agent max_steps override on RobotAgent
lukass16 Jul 18, 2026
6e4a811
refactor(robot): spawn every sim as its own fork-free process; declar…
lukass16 Jul 18, 2026
748ed4c
feat(eval): add Run.started carrying the full tasks.start reply
lukass16 Jul 20, 2026
b5258cd
feat(environment/robot): lock RobotEndpoint; reset/result carry a token
lukass16 Jul 20, 2026
fded695
feat(environment/robot): slot registry + barrier stepping in the bridge
lukass16 Jul 20, 2026
2c698d4
feat(capabilities/robot): RobotClient.connect claims a slot by token
lukass16 Jul 20, 2026
278376f
refactor(agents/robot): drop drive() for a single scalar rollout loop
lukass16 Jul 20, 2026
880b902
refactor(eval): drop vec_rollout/num_envs; add Shared provider
lukass16 Jul 20, 2026
aef1196
docs(robots): document sessions-based vectorized evals and Shared
lukass16 Jul 20, 2026
121d945
docs(agents): tighten BatchedModel and BatchedAgent docstrings
lukass16 Jul 20, 2026
8b42d5e
feat(agents): share one LeRobot dataset across concurrent rollouts
lukass16 Jul 20, 2026
b521d96
docs(robot): tighten RobotClient and Environment.gym docstrings
lukass16 Jul 20, 2026
42f6e28
refactor(environment/robot): fold SimThread into bridge; move GymBrid…
lukass16 Jul 20, 2026
d1d7e23
refactor(hud): drop lazy hud.wrap export from package root
lukass16 Jul 20, 2026
1df6832
docs(robot): point wrap at hud.environment.robot, not hud.wrap
lukass16 Jul 20, 2026
08d424e
docs(environment/robot): shorten the bridge module docstring
lukass16 Jul 20, 2026
0a2d2cf
fix(environment/robot): barrier over all claimed slots, not just conn…
lukass16 Jul 20, 2026
9884ccc
docs(agents/robot): clarify max_steps is a call-site kwarg with defau…
lukass16 Jul 20, 2026
9f0cc5a
fix(robot): fail fast when the slot claim is missing instead of deadl…
lukass16 Jul 20, 2026
5ae0641
fix(environment/robot): reject mismatched claim kwargs; fix hold shap…
lukass16 Jul 20, 2026
9e1df70
fix(agents/robot): honor subclass max_steps and skip acting when alre…
lukass16 Jul 20, 2026
083122f
fix(robot): barrier, claim, telemetry, and gym-argv robustness from r…
lukass16 Jul 20, 2026
e878d30
feat(robot): make the slot token optional for single-env bridges
lukass16 Jul 20, 2026
cc47fff
feat(environment/robot): lazy Isaac spawn — serve the wire before the…
lukass16 Jul 20, 2026
16104f3
fix(environment/robot): probe env at start when contract.json is missing
lukass16 Jul 21, 2026
21efad7
docs(robots): split env-side docs into gym-style envs and custom bridges
lukass16 Jul 21, 2026
37dc2ab
docs(robots): rewrite vectorized envs and evals section
lukass16 Jul 21, 2026
bb698af
docs(robots): simplify overview and recording notes, drop sim internals
lukass16 Jul 21, 2026
9c22754
fix(environment/robot): barrier holds, lazy contract, sim-thread reset
lukass16 Jul 21, 2026
23cf01a
fix(agents/robot): serialize DatasetWriter commits; label action plots
lukass16 Jul 21, 2026
92ab505
docs(robots): omit slot token in the single-env cookbook template
lukass16 Jul 21, 2026
f097fc4
fix(eval): seal Shared provision leaks and enforce width
lukass16 Jul 21, 2026
6ac7303
revert(cli): keep hud serve on asyncio.run(serve)
lukass16 Jul 21, 2026
708734f
chore(tests): drop robot/gym bridge unit tests from the PR
lukass16 Jul 21, 2026
0ff5bbc
chore(tests): drop Shared provider unit tests from the PR
lukass16 Jul 21, 2026
5b2bb53
fix(robot): clear ruff and pyright failures on the branch
lukass16 Jul 21, 2026
a43fec0
feat(environment/robot): RobotEndpoint spawns custom bridges on start
lukass16 Jul 21, 2026
394f162
fix(robot): forward bridge ctor kwargs; open DatasetWriter on first f…
lukass16 Jul 21, 2026
49f150a
fix(environment/robot): read episode grades on the sim thread
lukass16 Jul 21, 2026
5928734
feat(agents/robot): honor Adapter.chunk_size as open-loop horizon
lukass16 Jul 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
349 changes: 227 additions & 122 deletions docs/v6/advanced/robots.mdx

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions docs/v6/cookbooks/robot-benchmark.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,17 @@ endpoint = RobotEndpoint(LiberoSimBridge(use_delta=True)) # drive the bridge th
@env.initialize
async def _up():
await endpoint.start()
env.add_capability(await endpoint.capability(contract=CONTRACT))
env.add_capability(await endpoint.capability())

@env.shutdown
async def _down():
await endpoint.stop()

@env.template(id="libero_spatial")
async def libero_spatial(libero_task_id: int, init_state_id: int = 0):
prompt = await endpoint.reset(task_suite="libero_spatial",
task_id=libero_task_id, init_state_id=init_state_id)
yield {"prompt": prompt}
ep = await endpoint.reset(task_suite="libero_spatial",
task_id=libero_task_id, init_state_id=init_state_id)
yield {"prompt": ep["prompt"]}
yield await endpoint.result()
```

Expand Down Expand Up @@ -67,8 +67,6 @@ from hud.eval import DockerRuntime, Task, Taskset
CHECKPOINT = "lerobot/pi05_libero_finetuned"

class PI05Agent(RobotAgent):
max_steps = 400

def __init__(self):
device = "cuda" if torch.cuda.is_available() else "cpu"
policy = PI05Policy.from_pretrained(CHECKPOINT).to(device).eval()
Expand Down
18 changes: 18 additions & 0 deletions docs/v6/reference/runtime.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ await taskset.run(agent, runtime=LocalRuntime("env.py")) # serve env.py locall
| `ModalRuntime("my-env")` | A fresh [Modal](https://modal.com/) sandbox per rollout | Cloud scale, no infra to manage |
| `DaytonaRuntime("my-env")` | A fresh [Daytona](https://www.daytona.io/) sandbox per rollout | Cloud scale on Daytona |
| `Runtime("tcp://host:8765")` | A substrate you already started | Attaching to a long-lived container or sandbox you own |
| `Shared(provider, width=N)` | One substrate from `provider`, shared by up to N concurrent connections | Multi-agent evals - agents that share, compete, or coordinate over one live environment |
| `HUDRuntime()` | A HUD-hosted env, leased by name and tunneled | Local agent loop against a deployed env |
| `HostedRuntime()` | The whole rollout on a HUD-leased box | Agent and env run together off your machine |

Expand Down Expand Up @@ -182,6 +183,23 @@ Runtime(url, params=..., config=...)
- **`url`** - control-channel address of an already-running substrate (e.g. `tcp://host:8765`).
- **`params`** - connection-time data a transport may need (auth token, sandbox id).

### `Shared`

```python
Shared(provider, *, width)
```

- **`provider`** - the `Provider` to provision once, e.g. `DockerRuntime("my-env")`.
- **`width`** - how many concurrent connections that one substrate accepts.

The first rollout to acquire it provisions through `provider`; every later rollout is just handed
that same address instead of provisioning its own, so up to `width` rollouts end up connected to
one live substrate - agents that need to share, compete, or coordinate in the same environment
(a vectorized sim's `num_envs` slots, see [robots](/v6/advanced/robots#vectorized-envs-and-evals),
are one case). Pair it with `taskset.run(..., group=width, max_concurrent=width)` so exactly
`width` rollouts connect at once. `Runtime(url)` needs no wrapping since every caller already
dials the same address.

## Run on your own infra

A **runtime is just a function**: given a task, start a container somewhere and yield its
Expand Down
1 change: 1 addition & 0 deletions hud/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"instrument",
]


Comment thread
cursor[bot] marked this conversation as resolved.
try:
from .version import __version__
except ImportError:
Expand Down
42 changes: 20 additions & 22 deletions hud/agents/robot/__init__.py
Original file line number Diff line number Diff line change
@@ -1,41 +1,39 @@
"""Robot agent harness: drive a ``robot`` capability with a policy.

The harness splits a policy rollout into three seams, each replaceable on its own:

- :class:`~hud.agents.robot.agent.RobotAgent` — the loop: connect to the env's
``robot`` capability, observe, act, stop.
- :class:`~hud.agents.robot.model.Model` — *how to run* the policy (preprocess →
forward → postprocess). :class:`~hud.agents.robot.model.LeRobotModel` ships the
LeRobot checkpoint convention.
- :class:`~hud.agents.robot.adapter.Adapter` — translate between the env's
observation/action spaces (from the contract) and the policy's.

Wrap an agent in :class:`~hud.agents.robot.batching.BatchedAgent` to run many rollouts
concurrently off one batched GPU forward (``max_concurrent`` rollouts, shared model).

Per-tick platform tracing is emitted by the loop itself: each step records an
:class:`~hud.agents.types.ObservationStep`, and each re-inference an
:class:`~hud.agents.types.InferenceStep`, so runs stream live into the HUD trace viewer.

This subpackage needs the ``robot`` extra (``pip install 'hud[robot]'``) for
``numpy`` + ``msgpack``; importing :mod:`hud.agents` alone never pulls them in.
"""Agent-side robot harness: drive a ``robot`` env with a VLA policy.

- :class:`~.agent.RobotAgent` — the harness: connects to the ``robot``
capability (claiming a slot token from ``run.started``), reads the contract,
drives one scalar connection with an open-loop chunk queue. Subclass and
set ``self.model`` + ``self.adapter``.
- :class:`~.model.Model` / :class:`~.model.LeRobotModel` /
:class:`~.model.RemoteModel` — the policy and its inference mechanics.
- :class:`~.adapter.Adapter` / :class:`~.adapter.LeRobotAdapter` /
:class:`~.adapter.OpenPIAdapter` — env <-> policy space translation.
- :class:`~.batching.BatchedAgent` — many concurrent single-env rollouts
sharing one batched model.
- :class:`~.dataset.DatasetWriter` — opt-in LeRobot v3 dataset recording
(``agent.save = True``).

This subpackage needs the ``robot`` extra (``pip install 'hud[robot]'``).
"""

from __future__ import annotations

from .adapter import Adapter, LeRobotAdapter, OpenPIAdapter
from .agent import ROBOT_PROTOCOL, RobotAgent
from .batching import BatchedAgent, BatchedModel
from .model import LeRobotModel, Model
from .dataset import DatasetWriter
from .model import LeRobotModel, Model, RemoteModel

__all__ = [
"ROBOT_PROTOCOL",
"Adapter",
"BatchedAgent",
"BatchedModel",
"DatasetWriter",
"LeRobotAdapter",
"LeRobotModel",
"Model",
"OpenPIAdapter",
"RemoteModel",
"RobotAgent",
]
12 changes: 0 additions & 12 deletions hud/agents/robot/_types.py

This file was deleted.

101 changes: 57 additions & 44 deletions hud/agents/robot/adapter.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,59 @@
"""Translate observations and actions between env and policy spaces.

The loop calls ``bind``, ``reset``, ``adapt_observation``, and ``adapt_action``.
The loop calls ``bind``, ``reset``, ``adapt_observation``, and the action hooks.
Use :class:`LeRobotAdapter` for LeRobot models; subclass for custom wiring;
``adapter=None`` for pass-through.
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any
from typing import Any

import numpy as np
from numpy.typing import NDArray

if TYPE_CHECKING:
from ._types import ActionArray
#: A policy-emitted action / chunk array (the robot stack's shared alias).
ActionArray = NDArray[np.floating[Any]]

# ─── the abstraction ──────────────────────────────────────────────────────────
#: Image types the bundled adapters treat as cameras (vs the state vector).
IMAGE_TYPES = ("rgb", "bgr", "gray", "depth")


class Adapter:
"""Translate between an env's observation/action spaces and a policy's.

Driven by :class:`~hud.agents.robot.agent.RobotAgent`: :meth:`bind` once after
connect, :meth:`reset` once per episode, then :meth:`adapt_observation` /
:meth:`adapt_action` each step. Construct with the policy's image-slot names;
everything env-side is learned in :meth:`bind`.
Driven by :class:`~hud.agents.robot.agent.RobotAgent`: :meth:`bind` once
after connect, :meth:`reset` once per episode, then :meth:`adapt_observation`
per inference and the action hooks per chunk/step. Construct with the
policy's image-slot names; everything env-side is learned in :meth:`bind`.
"""

def __init__(self, *, model_image_keys: list[str] | None = None) -> None:
def __init__(
self,
*,
model_image_keys: list[str] | None = None,
chunk_size: int | None = None,
) -> None:
#: The policy's ordered image-slot names (model side; known at load time).
self.model_image_keys: list[str] = list(model_image_keys or [])
#: The env's selected action feature (set in :meth:`bind`).
#: Open-loop horizon: how many predicted actions to execute before re-querying.
#: ``None`` = full model chunk. Applied by :class:`~.agent.RobotAgent`, not here —
#: so subclass ``adapt_chunk`` overrides stay free of truncation duty.
self.chunk_size: int | None = chunk_size
#: The env's action feature and observation layout (set in :meth:`bind`).
self.action_space: dict[str, Any] = {}
#: The env's image / state observation keys (set in :meth:`bind`).
self.image_keys: list[str] = []
self.state_key: str | None = None

def bind(self, action_space: dict[str, Any], observation_space: dict[str, Any]) -> None:
"""Learn the env's layout from the contract (``client.spaces()``).

Splits observation features into image keys vs the single state key and stores
the action feature. Override to derive extra env-side parameters.
"""
# TODO CLEAN
"""Learn the env's layout from the contract (``client.spaces()``): image
features become the camera keys (in contract order), the first non-image
feature is the state. Override to derive extra env-side parameters."""
self.action_space = action_space or {}
image_types = ("rgb", "bgr", "gray", "depth")
self.image_keys = []
self.state_key = None
for name, feature in observation_space.items():
if feature.get("type") in image_types:
self.image_keys.append(name)
elif self.state_key is None:
self.state_key = name
self.image_keys = [n for n, f in observation_space.items() if f.get("type") in IMAGE_TYPES]
self.state_key = next(
(n for n, f in observation_space.items() if f.get("type") not in IMAGE_TYPES), None
)

def reset(self) -> None:
"""Override only if the adapter is stateful across steps within an episode."""
Expand All @@ -59,47 +62,57 @@ def adapt_observation(self, obs: dict[str, Any], prompt: str) -> Any:
"""Translate an env observation + task prompt into the policy's input."""
raise NotImplementedError

def adapt_chunk(self, chunk: ActionArray, obs: dict[str, Any]) -> ActionArray:
"""Translate a freshly-inferred ``[T, A]`` chunk to env space, given the
(per-slot) query-time observation it was inferred from (default identity).

Called once per slot at inference time, so a chunk expressed relative to
the query state — e.g. joint *deltas* to be added to the query-time
joints — converts in one shot.
"""
return chunk

def adapt_action(self, action: ActionArray, obs: dict[str, Any]) -> ActionArray:
"""Translate a policy action into the env's action space (default identity)."""
"""Per-step execution-time hook on the popped action (default identity)."""
return action


class LeRobotAdapter(Adapter):
"""Vanilla LeRobot adapter for a standard image/state env.
"""Vanilla LeRobot adapter for a standard image/state env, single or batched.

Maps env cameras onto the model's image slots in order, converts HWC ``uint8`` to
CHW ``float`` in ``[0, 1]``, and passes state + prompt through. Actions are identity
(postprocess already returns env-space actions); subclass for resize/pad/reshaping.
Maps env cameras onto the model's image slots in order and converts HWC
``uint8`` to CHW ``float`` in ``[0, 1]``; state and prompt pass through.
A batched observation (state ``[N, S]``) maps in one go — cameras become
``[N, C, H, W]`` and the shared task is repeated to ``N``. Actions are
identity (postprocess already returns env-space actions).
"""

def adapt_observation(self, obs: dict[str, Any], prompt: str) -> dict[str, Any]:
import torch # pyright: ignore[reportMissingImports]

torch_mod: Any = torch
torch_mod: Any = torch # torch ships no stubs; keep strict mode quiet
data = obs["data"]
state = np.asarray(data[self.state_key], dtype=np.float32)
batched = state.ndim > 1 # [N, S] vs [S]
batch: dict[str, Any] = {
"observation.state": torch_mod.from_numpy(data[self.state_key].astype(np.float32)),
"task": prompt,
"observation.state": torch_mod.from_numpy(state),
"task": [prompt] * len(state) if batched else prompt,
}
for model_key, env_key in zip(self.model_image_keys, self.image_keys, strict=False):
batch[model_key] = torch_mod.from_numpy(data[env_key]).permute(2, 0, 1).float() / 255.0
img = torch_mod.from_numpy(np.asarray(data[env_key]))
perm = (0, 3, 1, 2) if batched else (2, 0, 1)
batch[model_key] = img.permute(*perm).float() / 255.0
return batch

def adapt_action(self, action: ActionArray, obs: dict[str, Any]) -> ActionArray:
return action


class OpenPIAdapter(Adapter):
"""unwraps obs['data'] to OpenPI wire keys, attaches prompt; actions are passthrough"""
"""Unwraps ``obs['data']`` to OpenPI wire keys and attaches the prompt;
actions are pass-through."""

def adapt_observation(self, obs: dict[str, Any], prompt: str) -> dict[str, Any]:
out = dict(obs["data"])
out.setdefault("prompt", prompt)
return out


__all__ = [
"Adapter",
"LeRobotAdapter",
"OpenPIAdapter",
]
__all__ = ["ActionArray", "Adapter", "LeRobotAdapter", "OpenPIAdapter"]
Loading
Loading