Run Inspect Robots evals on real SO-ARM followers (SO-100 / SO-101) driven by LeRobot policies.
[!NOTE] This project is in early development. The API may change between releases, so pin a version before depending on it.
Inspect Robots has two swappable inputs: a Policy (the VLA brain) and an
Embodiment (the robot body + world). This package provides both for the
SO-ARM + LeRobot stack, so any embodiment-agnostic Inspect Robots task runs on a real
arm:
lerobot policy: wraps a LeRobot checkpoint (ACT, SmolVLA, π0, diffusion…)
and runs it in process on the GPU, returning an action chunk per inference.so_arm embodiment: the LeRobot SO follower driver (Feetech bus), with a
hard safety clamp, camera frames validated against the configured resolution,
operator-in-the-loop episode end, and self-paced control.Both declare the same 6-D joint-position contract (shoulder_pan,
shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper; the cameras
you configure; packed joint_pos state), so Inspect Robots's compatibility check passes
with zero errors and zero warnings, verifiable before any motion.
inspect-robots run --instruction "Reach for the cube" --policy lerobot --embodiment so_arm
This is the SO-ARM/LeRobot sibling of inspect-robots-yam (bimanual I2RT YAM + MolmoAct2). Same Inspect Robots contract, different body and brain.
# Inspect Robots resolves from PyPI. The `lerobot` extra pulls
# torch + lerobot + the Feetech motor bus the SO follower uses.
uv pip install "inspect-robots-so101[lerobot] @ git+https://github.com/robocurve/inspect-robots-so101"
lerobot → lerobot[feetech] (torch, the policy, and the SO-ARM driver).lerobot extra needs Python ≥ 3.12 (lerobot ≥ 0.5's floor). On
3.10/3.11 the extra silently resolves to nothing: the core package still
imports, but no torch/lerobot is installed and hardware runs will fail.Then pick a checkpoint. Any LeRobot policy trained on your SO-ARM works, e.g. the
public lerobot/smolvla_base, or your own ACT/π0 checkpoint on the Hub or a path.
inspect-robots-so101-preflight # dims/semantics/cameras/state
inspect-robots-so101-preflight --task cubepick-reach # + scene realizability
inspect-robots-so101-preflight --dry-run # affirm no motion
A green preflight means action dim (6), control mode (joint_pos), cameras, and
state keys all line up. It does not prove the joint values are interpreted the
same way (see Safety below).
The embodiment never runs lerobot's interactive calibration: connecting with an uncalibrated arm would otherwise drop into a blocking prompt that moves the arm mid-eval. Calibrate once with lerobot's own tool, then tell the config which identity you used:
lerobot-calibrate --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=my_follower
SOArmConfig(robot_id="my_follower") selects that calibration file
(<calibration_dir>/<robot_id>.json; leave calibration_dir=None for lerobot's
default location). If the arm isn't calibrated, or the file no longer matches
the motors, reset() fails fast with an actionable error instead of prompting.
You must point the embodiment at your serial port, calibration id, and camera config, and the policy at a checkpoint:
from inspect_robots import Scene, Task, eval, operator_scorer
from inspect_robots.approver import ClampApprover
from inspect_robots_so101 import LeRobotPolicy, SOArmEmbodiment, SOArmConfig, LeRobotPolicyConfig
from lerobot.cameras.opencv import OpenCVCameraConfig # your camera backend
def grade_trial(record, _scene):
record.operator_judgement = input("Outcome? [y/n/partial/skip]: ")
record.operator_note = input("Grader note (optional): ")
task = Task(
name="operator-graded-reach",
scenes=[Scene(id="reach", instruction="Reach for the cube")],
scorer=operator_scorer(),
max_steps=1200,
)
emb = SOArmEmbodiment(SOArmConfig(
port="/dev/ttyACM0",
robot_type="so101_follower",
robot_id="my_follower", # the id you ran `lerobot-calibrate` with
max_relative_target=10.0, # native-unit slew limit; required for home_pose
cameras=("front",),
camera_configs={"front": OpenCVCameraConfig(index_or_path=0, width=640, height=480, fps=30)},
))
pol = LeRobotPolicy(LeRobotPolicyConfig(
pretrained_path="lerobot/smolvla_base", policy_type="smolvla", device="cuda",
))
with emb: # guarantees disconnect (and torque-off) even if the eval raises
(log,) = eval(task, pol, emb,
approver=ClampApprover(emb.info.action_space),
before_scoring=grade_trial) # records the verdict before scoring
print(log.status, log.results.metrics)
(Equivalently, wrap the eval(...) in try: ... finally: emb.close().)
Pressing the end-episode key terminates with
termination_reason="operator_end". The embodiment itself asks no grading
questions. On attended CLI runs, the framework then asks once per trial for a
[y/n/partial/skip] verdict and an optional grader note.
Prompting and scoring are separate. Adhoc --instruction runs, such as the CLI
example above, default to the operator scorer and score the recorded verdict.
Registered tasks bring their own scorers, and the CLI rejects --scorer for
them. The built-in cubepick-reach task uses success_at_end, which never
reads operator judgements. An attended --task cubepick-reach run therefore
collects a verdict but scores 0.0.
Direct Python eval() calls never show the framework's CLI prompt. The example
uses both pieces required for operator grading: before_scoring records the
verdict, and the inline task's operator_scorer() reads it. A hook alone does
not change a task's scorer, while operator_scorer() alone has no judgement to
read.
[!WARNING] Do not pair
success_at_endwith attended operator-graded runs. It counts only embodiment-detected"success"terminations, so it scoresoperator_endas a failure.
The readiness prompt needs an interactive terminal. A closed or dead stdin
raises EmbodimentFault with instructions to use a real TTY or inject
OperatorIO(input_fn=...); an open but silent pipe can still block.
SOArmConfig.joint_low/high
inside step(), independent of any Inspect Robots Approver and on top of LeRobot's
own max_relative_target slew limit. Unclamped model outputs can never reach
the motors. Set these to your real, calibrated SO-ARM joint limits (the
defaults are conservative placeholders: joints ±180° in degree mode or ±100
in normalized mode, with the gripper at 0–100 in both modes).ClampApprover on hardware for a second layer.use_degrees value on
SOArmConfig and LeRobotPolicyConfig: True uses degrees for arm joints,
while False uses LeRobot's normalized ±100 joint positions. The gripper is
0–100 in both modes. The declared state specification and automatic clamp
bounds follow that value. Inspect Robots compatibility currently compares
state keys, not units, so it will not flag different values across components
or unit mismatches with third-party counterparts. Verify units when mixing
stacks.home_pose sends a single absolute
command, so the config requires max_relative_target (LeRobot's per-step slew
limit) whenever home_pose is set. Otherwise the arm would slam to home at
full speed from wherever it happens to be.SOArmConfig(joints_are_delta=True) (the embodiment converts to absolute
internally so the declared joint_pos stays honest). The compat check cannot
tell these apart. Confirm with --dry-run and a single slow jog before a task.SOArmConfig: port, robot_type, robot_id, calibration_dir, cameras,
camera_configs, control_hz, cam_height/width, joint_low/high,
home_pose (requires max_relative_target), joints_are_delta, use_degrees
(defaults to True), max_relative_target, disable_torque_on_disconnect,
settle_tolerance (default None), settle_timeout_s (default 1.0),
settle_timeout_budget (default 20).
robot_type is validated (so101_follower / so100_follower) but is a label:
at lerobot v0.5.x both names alias the same driver class, so it changes no
runtime behavior.
LeRobotPolicyConfig: pretrained_path, policy_type, device, cameras,
state_key, chunk_size, cam_height/width, use_degrees. Set its
use_degrees value to match the embodiment.
Scalar knobs are settable from the CLI:
inspect-robots run -P pretrained_path=lerobot/smolvla_base -E port=/dev/ttyACM0 ....
By default, step() commands a pose, paces out the control period, and
observes without checking that the arm arrived. LeRobot's send_action()
returns immediately, so a chunked policy can finish replaying a chunk and plan
its next motion from a pose the arm has not reached.
Set settle_tolerance to make step() and homing in reset() poll the driver
before observing:
inspect-robots run --instruction "Reach for the cube" --policy lerobot --embodiment so_arm \
-E settle_tolerance=2.0 -E settle_timeout_s=1.0 -E settle_timeout_budget=20
The tolerance uses the configured action units: degrees when use_degrees=True
and LeRobot normalized units otherwise. Choose it from measurements on your
rig. Settling is off by default so closed-loop VLA cadence is unchanged.
Only the five arm joints are checked. The gripper is excluded because one
closing on an object may never reach its target. Settling also targets the
action the driver accepted, after its internal max_relative_target
truncation, rather than the larger pose the policy originally requested.
Timeouts are not trial failures. The step observes anyway and reports
settle_timeouts, plus settled and settle_residual when a wait ran, in
StepResult.info. After settle_timeout_budget timeouts, settling disables
itself for the rest of that trial, logs one warning with the worst motor and
residual, and reports settle_disabled=True. The next reset() clears the
counter and enables settling again.
With settling enabled, control_hz is a floor on step duration. A slow move or
timeout can make a step take longer than one control period.
Dependency changes: after editing dependencies in
pyproject.toml, runuv lockand commit the updated lockfile: CI installs withuv sync --lockedand fails with "the lockfile needs to be updated" if you forget. Day-to-day conventions (PR-onlymain, the requiredci-okcheck, one-click releases) are documented inCLAUDE.md.
Every public module, class, and function needs a docstring, enforced by Ruff D1; state the contract, do not restate the name.
uv venv && uv pip install -e ".[dev]" # inspect-robots from PyPI
uv run pre-commit install
uv run pytest --cov # 100% coverage required
uv run ruff check . && uv run mypy
The whole suite runs with no hardware, no GPU, no torch, no lerobot, and no
stdin: the SO-ARM driver, the policy inference, the clock, and operator I/O are
all injected. The real model seam (_default_predict) is covered via
sys.modules fakes, and a dedicated lerobot-seam CI job (py3.12) imports the
real lerobot symbols it uses; only direct hardware/TTY I/O keeps
# pragma: no cover.
Python
100.0%
Run Inspect Robots evals on real SO-ARM followers (SO-100 / SO-101) driven by LeRobot policies.
[!NOTE] This project is in early development. The API may change between releases, so pin a version before depending on it.
Inspect Robots has two swappable inputs: a Policy (the VLA brain) and an
Embodiment (the robot body + world). This package provides both for the
SO-ARM + LeRobot stack, so any embodiment-agnostic Inspect Robots task runs on a real
arm:
lerobot policy: wraps a LeRobot checkpoint (ACT, SmolVLA, π0, diffusion…)
and runs it in process on the GPU, returning an action chunk per inference.so_arm embodiment: the LeRobot SO follower driver (Feetech bus), with a
hard safety clamp, camera frames validated against the configured resolution,
operator-in-the-loop episode end, and self-paced control.Both declare the same 6-D joint-position contract (shoulder_pan,
shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper; the cameras
you configure; packed joint_pos state), so Inspect Robots's compatibility check passes
with zero errors and zero warnings, verifiable before any motion.
inspect-robots run --instruction "Reach for the cube" --policy lerobot --embodiment so_arm
This is the SO-ARM/LeRobot sibling of inspect-robots-yam (bimanual I2RT YAM + MolmoAct2). Same Inspect Robots contract, different body and brain.
# Inspect Robots resolves from PyPI. The `lerobot` extra pulls
# torch + lerobot + the Feetech motor bus the SO follower uses.
uv pip install "inspect-robots-so101[lerobot] @ git+https://github.com/robocurve/inspect-robots-so101"
lerobot → lerobot[feetech] (torch, the policy, and the SO-ARM driver).lerobot extra needs Python ≥ 3.12 (lerobot ≥ 0.5's floor). On
3.10/3.11 the extra silently resolves to nothing: the core package still
imports, but no torch/lerobot is installed and hardware runs will fail.Then pick a checkpoint. Any LeRobot policy trained on your SO-ARM works, e.g. the
public lerobot/smolvla_base, or your own ACT/π0 checkpoint on the Hub or a path.
inspect-robots-so101-preflight # dims/semantics/cameras/state
inspect-robots-so101-preflight --task cubepick-reach # + scene realizability
inspect-robots-so101-preflight --dry-run # affirm no motion
A green preflight means action dim (6), control mode (joint_pos), cameras, and
state keys all line up. It does not prove the joint values are interpreted the
same way (see Safety below).
The embodiment never runs lerobot's interactive calibration: connecting with an uncalibrated arm would otherwise drop into a blocking prompt that moves the arm mid-eval. Calibrate once with lerobot's own tool, then tell the config which identity you used:
lerobot-calibrate --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=my_follower
SOArmConfig(robot_id="my_follower") selects that calibration file
(<calibration_dir>/<robot_id>.json; leave calibration_dir=None for lerobot's
default location). If the arm isn't calibrated, or the file no longer matches
the motors, reset() fails fast with an actionable error instead of prompting.
You must point the embodiment at your serial port, calibration id, and camera config, and the policy at a checkpoint:
from inspect_robots import Scene, Task, eval, operator_scorer
from inspect_robots.approver import ClampApprover
from inspect_robots_so101 import LeRobotPolicy, SOArmEmbodiment, SOArmConfig, LeRobotPolicyConfig
from lerobot.cameras.opencv import OpenCVCameraConfig # your camera backend
def grade_trial(record, _scene):
record.operator_judgement = input("Outcome? [y/n/partial/skip]: ")
record.operator_note = input("Grader note (optional): ")
task = Task(
name="operator-graded-reach",
scenes=[Scene(id="reach", instruction="Reach for the cube")],
scorer=operator_scorer(),
max_steps=1200,
)
emb = SOArmEmbodiment(SOArmConfig(
port="/dev/ttyACM0",
robot_type="so101_follower",
robot_id="my_follower", # the id you ran `lerobot-calibrate` with
max_relative_target=10.0, # native-unit slew limit; required for home_pose
cameras=("front",),
camera_configs={"front": OpenCVCameraConfig(index_or_path=0, width=640, height=480, fps=30)},
))
pol = LeRobotPolicy(LeRobotPolicyConfig(
pretrained_path="lerobot/smolvla_base", policy_type="smolvla", device="cuda",
))
with emb: # guarantees disconnect (and torque-off) even if the eval raises
(log,) = eval(task, pol, emb,
approver=ClampApprover(emb.info.action_space),
before_scoring=grade_trial) # records the verdict before scoring
print(log.status, log.results.metrics)
(Equivalently, wrap the eval(...) in try: ... finally: emb.close().)
Pressing the end-episode key terminates with
termination_reason="operator_end". The embodiment itself asks no grading
questions. On attended CLI runs, the framework then asks once per trial for a
[y/n/partial/skip] verdict and an optional grader note.
Prompting and scoring are separate. Adhoc --instruction runs, such as the CLI
example above, default to the operator scorer and score the recorded verdict.
Registered tasks bring their own scorers, and the CLI rejects --scorer for
them. The built-in cubepick-reach task uses success_at_end, which never
reads operator judgements. An attended --task cubepick-reach run therefore
collects a verdict but scores 0.0.
Direct Python eval() calls never show the framework's CLI prompt. The example
uses both pieces required for operator grading: before_scoring records the
verdict, and the inline task's operator_scorer() reads it. A hook alone does
not change a task's scorer, while operator_scorer() alone has no judgement to
read.
[!WARNING] Do not pair
success_at_endwith attended operator-graded runs. It counts only embodiment-detected"success"terminations, so it scoresoperator_endas a failure.
The readiness prompt needs an interactive terminal. A closed or dead stdin
raises EmbodimentFault with instructions to use a real TTY or inject
OperatorIO(input_fn=...); an open but silent pipe can still block.
SOArmConfig.joint_low/high
inside step(), independent of any Inspect Robots Approver and on top of LeRobot's
own max_relative_target slew limit. Unclamped model outputs can never reach
the motors. Set these to your real, calibrated SO-ARM joint limits (the
defaults are conservative placeholders: joints ±180° in degree mode or ±100
in normalized mode, with the gripper at 0–100 in both modes).ClampApprover on hardware for a second layer.use_degrees value on
SOArmConfig and LeRobotPolicyConfig: True uses degrees for arm joints,
while False uses LeRobot's normalized ±100 joint positions. The gripper is
0–100 in both modes. The declared state specification and automatic clamp
bounds follow that value. Inspect Robots compatibility currently compares
state keys, not units, so it will not flag different values across components
or unit mismatches with third-party counterparts. Verify units when mixing
stacks.home_pose sends a single absolute
command, so the config requires max_relative_target (LeRobot's per-step slew
limit) whenever home_pose is set. Otherwise the arm would slam to home at
full speed from wherever it happens to be.SOArmConfig(joints_are_delta=True) (the embodiment converts to absolute
internally so the declared joint_pos stays honest). The compat check cannot
tell these apart. Confirm with --dry-run and a single slow jog before a task.SOArmConfig: port, robot_type, robot_id, calibration_dir, cameras,
camera_configs, control_hz, cam_height/width, joint_low/high,
home_pose (requires max_relative_target), joints_are_delta, use_degrees
(defaults to True), max_relative_target, disable_torque_on_disconnect,
settle_tolerance (default None), settle_timeout_s (default 1.0),
settle_timeout_budget (default 20).
robot_type is validated (so101_follower / so100_follower) but is a label:
at lerobot v0.5.x both names alias the same driver class, so it changes no
runtime behavior.
LeRobotPolicyConfig: pretrained_path, policy_type, device, cameras,
state_key, chunk_size, cam_height/width, use_degrees. Set its
use_degrees value to match the embodiment.
Scalar knobs are settable from the CLI:
inspect-robots run -P pretrained_path=lerobot/smolvla_base -E port=/dev/ttyACM0 ....
By default, step() commands a pose, paces out the control period, and
observes without checking that the arm arrived. LeRobot's send_action()
returns immediately, so a chunked policy can finish replaying a chunk and plan
its next motion from a pose the arm has not reached.
Set settle_tolerance to make step() and homing in reset() poll the driver
before observing:
inspect-robots run --instruction "Reach for the cube" --policy lerobot --embodiment so_arm \
-E settle_tolerance=2.0 -E settle_timeout_s=1.0 -E settle_timeout_budget=20
The tolerance uses the configured action units: degrees when use_degrees=True
and LeRobot normalized units otherwise. Choose it from measurements on your
rig. Settling is off by default so closed-loop VLA cadence is unchanged.
Only the five arm joints are checked. The gripper is excluded because one
closing on an object may never reach its target. Settling also targets the
action the driver accepted, after its internal max_relative_target
truncation, rather than the larger pose the policy originally requested.
Timeouts are not trial failures. The step observes anyway and reports
settle_timeouts, plus settled and settle_residual when a wait ran, in
StepResult.info. After settle_timeout_budget timeouts, settling disables
itself for the rest of that trial, logs one warning with the worst motor and
residual, and reports settle_disabled=True. The next reset() clears the
counter and enables settling again.
With settling enabled, control_hz is a floor on step duration. A slow move or
timeout can make a step take longer than one control period.
Dependency changes: after editing dependencies in
pyproject.toml, runuv lockand commit the updated lockfile: CI installs withuv sync --lockedand fails with "the lockfile needs to be updated" if you forget. Day-to-day conventions (PR-onlymain, the requiredci-okcheck, one-click releases) are documented inCLAUDE.md.
Every public module, class, and function needs a docstring, enforced by Ruff D1; state the contract, do not restate the name.
uv venv && uv pip install -e ".[dev]" # inspect-robots from PyPI
uv run pre-commit install
uv run pytest --cov # 100% coverage required
uv run ruff check . && uv run mypy
The whole suite runs with no hardware, no GPU, no torch, no lerobot, and no
stdin: the SO-ARM driver, the policy inference, the clock, and operator I/O are
all injected. The real model seam (_default_predict) is covered via
sys.modules fakes, and a dedicated lerobot-seam CI job (py3.12) imports the
real lerobot symbols it uses; only direct hardware/TTY I/O keeps
# pragma: no cover.
Python
100.0%