Compare commits

..

4 Commits

Author SHA1 Message Date
Steven Palma fde5db8406 feat(rollout): draft for ask in wallx 2026-08-07 18:49:25 +02:00
Steven Palma 39c4e746f1 feat(rollout): add subtask command 2026-08-07 16:44:29 +02:00
Steven Palma d3ee0b820c feat(rollout): mute logs in interactive mode 2026-08-07 16:00:56 +02:00
Steven Palma 072c697c0e feat(rollout): interactive v1 2026-08-07 15:22:44 +02:00
47 changed files with 2759 additions and 2130 deletions
@@ -33,7 +33,7 @@ jobs:
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.conclusion == 'success' &&
github.repository == 'huggingface/lerobot'
uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
uses: huggingface/doc-builder/.github/workflows/upload_pr_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
with:
package_name: lerobot
secrets:
+7 -28
View File
@@ -24,24 +24,19 @@ on:
required: false
type: string
# Triggers on pushes to main that touch the docs or the sources the API reference is generated from.
# `src/**` is included because the API reference is built from docstrings via `[[autodoc]]`: without it,
# published API pages would go stale as soon as a docstring changed.
# Triggers the workflow on push events to main for the docs folder
push:
branches:
- main
paths:
- "docs/**"
- "src/**"
# Same for pull requests, so a docstring change gets a preview build and a broken `[[autodoc]]` path
# fails the PR rather than main.
# Triggers the workflow on pull request events targeting main for the docs folder
pull_request:
branches:
- main
paths:
- "docs/**"
- "src/**"
release:
types: [published]
@@ -60,29 +55,16 @@ jobs:
github.repository == 'huggingface/lerobot'
permissions:
contents: read
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
uses: huggingface/doc-builder/.github/workflows/build_main_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
with:
commit_sha: ${{ github.sha }}
package: lerobot
# The shared workflow builds its venv with the runner's system Python, which is 3.10 on
# ubuntu-22.04. lerobot requires >=3.12, so without this the install fails during setup —
# before `pre_command` below ever runs. Added upstream in huggingface/doc-builder#808.
python_version: "3.12"
# doc-builder ships a mock-deps registry entry for lerobot, so the reusable workflow takes its
# "light install" path: `pip install ./lerobot --no-deps` plus a handful of real dependencies.
# That is not enough to import lerobot — draccus runs `register_subclass` at import time and
# `processor/converters.py` calls `functools.singledispatch.register(torch.Tensor)`, neither of
# which works against a mock. Install the package for real before the build.
pre_command: uv pip install "./lerobot[dataset]"
# `--version main` is load-bearing: without `--not_python_module`, doc-builder falls back to
# `lerobot.__version__` and only maps that to the default branch when it contains "dev". Our main
# branch carries a release version (0.6.2), so omitting this would publish the main docs to
# /lerobot/v0.6.2/ instead of /lerobot/main/ and disable notebook building.
additional_args: >-
--not_python_module
${{
(github.event_name == 'release' && format('--version {0}', github.event.release.tag_name)) ||
(inputs.version != '' && format('--version {0}', inputs.version)) ||
'--version main'
''
}}
secrets:
token: ${{ secrets.HUGGINGFACE_PUSH }}
@@ -96,12 +78,9 @@ jobs:
permissions:
contents: read
pull-requests: write
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@931031bf2b54aabb134ceb54980a6a2860a00f11 # main
uses: huggingface/doc-builder/.github/workflows/build_pr_documentation.yml@6108e850ae1cf2f71bb0815a600bcd50c39abfa7 # main
with:
commit_sha: ${{ github.event.pull_request.head.sha }}
pr_number: ${{ github.event.number }}
package: lerobot
# See the comment on build_main_docs. The PR workflow passes its own `--version pr_<n>`, so no
# additional_args are needed here.
python_version: "3.12"
pre_command: uv pip install "./lerobot[dataset]"
additional_args: --not_python_module
-38
View File
@@ -56,41 +56,3 @@ jobs:
uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
with:
extra_args: --all-files --show-diff-on-failure --color=always
# This job runs the examples in our docstrings and validates the doctest allowlist.
# See docs/source/writing_docstrings.mdx for the standard these enforce.
doc-checks:
name: Run Documentation Checks (Doctests)
runs-on: ubuntu-latest
env:
# Examples that need a physical robot, a serial port or a Hub download are skipped by content.
# Everything else has to actually run. See src/lerobot/utils/doctest_utils.py.
SKIP_HARDWARE_DOCTEST: "1"
SKIP_CUDA_DOCTEST: "1"
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Setup uv and Python
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
version: "0.11.30"
python-version: "3.12"
- name: Install dependencies
run: uv sync --locked --extra test --extra dataset
- name: Check the doctest list is sorted and its paths exist
run: make check-doctest-list
- name: Check documented arguments match their signatures
run: make check-docstrings
- name: Check docstring coverage has not regressed
run: uv run --with interrogate interrogate --config=pyproject.toml
- name: Run doctests
run: make doctest
+2 -11
View File
@@ -67,11 +67,7 @@ repos:
args: [--prose-wrap=preserve]
# Jinja2 model-card templates use a .md extension but contain {% ... %} /
# {{ ... }} tags that prettier's Markdown formatter mangles (e.g. table loops).
#
# docs/source/api/ holds the generated API reference. Its `[[autodoc]]` blocks restrict output
# to an indented `- member` list, which prettier reads as a lazy paragraph continuation and
# joins onto one line — silently turning a member list into part of the directive.
exclude: ^(src/lerobot/templates/.*\.md|docs/source/api/.*\.mdx)$
exclude: ^src/lerobot/templates/.*\.md$
##### Security #####
- repo: https://github.com/gitleaks/gitleaks
@@ -108,13 +104,8 @@ repos:
# args: ["--docstring-style", "google", "-v", "2"]
# exclude: ^tests/.*$
# interrogate runs in CI (quality.yml, doc-checks job) rather than here. Its 1.7.0 release still imports
# the deprecated `py` package, which resolves against whatever `py` happens to be importable in
# pre-commit's isolated env — on a machine with miniconda on the path that is a stray `py.py` and the
# hook dies before it reads any config. The gate is the same either way; the CI step is just reliable.
# - repo: https://github.com/econchick/interrogate
# rev: 1.7.0
# hooks:
# - id: interrogate
# args: ["--config=pyproject.toml"]
# pass_filenames: false
# args: ["-vv", "--config=pyproject.toml"]
-4
View File
@@ -50,10 +50,6 @@ To run checks manually on all files:
pre-commit run --all-files
```
### Docstrings
The API reference is generated from the docstrings in `src/lerobot/`. If you add or change anything public, follow the [docstring standard](https://huggingface.co/docs/lerobot/writing_docstrings) — the format is parsed by the renderer and checked in CI.
### Running Tests
We use `pytest`. First, ensure you have test artifacts by installing **git-lfs**:
-26
View File
@@ -184,29 +184,3 @@ test-smolvla-ete-eval:
# backend, so it does not require a real model checkpoint or GPU.
annotation-e2e:
uv run python -m tests.annotations.run_e2e_smoke
# Docstring & doctest checks. See docs/source/writing_docstrings.mdx for the standard these enforce.
# Run the examples in the docstrings listed in utils/documentation_tests.txt. Hardware and GPU examples are
# skipped by content (see src/lerobot/utils/doctest_utils.py); CI sets both flags.
doctest:
@files=$$(grep -v '^\s*#' utils/documentation_tests.txt | grep -v '^\s*$$'); \
if [ -z "$$files" ]; then \
echo "utils/documentation_tests.txt lists no files; nothing to run."; \
else \
SKIP_HARDWARE_DOCTEST=1 uv run pytest --doctest-modules --no-header -q $$files; \
fi
check-doctest-list:
uv run python utils/check_doctest_list.py
fix-doctest-list:
uv run python utils/check_doctest_list.py --fix_and_overwrite
check-docstrings:
uv run python utils/check_docstrings.py
uv run python utils/check_config_docstrings.py
fix-docstrings:
uv run python utils/check_docstrings.py --fix_and_overwrite
uv run python utils/check_doctest_list.py --fix_and_overwrite
-60
View File
@@ -1,60 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Root conftest: makes doctest collection use LeRobot's parser.
This only affects `--doctest-modules` runs (see `make doctest`). The test suite itself is configured by
`tests/conftest.py`.
"""
import doctest
import _pytest.doctest
from lerobot.utils.doctest_utils import LeRobotDoctestModule, LeRobotDocTestParser
# Lets an example opt out of output comparison with `# doctest: +IGNORE_RESULT`, for calls whose output is
# a progress bar or otherwise not reproducible.
IGNORE_RESULT = doctest.register_optionflag("IGNORE_RESULT")
OutputChecker = doctest.OutputChecker
class CustomOutputChecker(OutputChecker):
"""An output checker that honours the `IGNORE_RESULT` flag."""
def check_output(self, want, got, optionflags):
"""Return `True` when `IGNORE_RESULT` is set, otherwise defer to stdlib.
Args:
want (`str`):
The expected output.
got (`str`):
The actual output.
optionflags (`int`):
Bitmask of active doctest option flags.
Returns:
`bool`: Whether the output is considered a match.
"""
if IGNORE_RESULT & optionflags:
return True
return OutputChecker.check_output(self, want, got, optionflags)
# Reassigning these module attributes is how doctest behaviour is customised; mypy sees it as assigning to
# a type, which is exactly what is intended here.
doctest.OutputChecker = CustomOutputChecker # type: ignore[misc]
_pytest.doctest.DoctestModule = LeRobotDoctestModule
doctest.DocTestParser = LeRobotDocTestParser # type: ignore[misc]
-22
View File
@@ -191,28 +191,6 @@
- sections:
- local: contributing
title: Contribute to LeRobot
- local: writing_docstrings
title: Writing docstrings
- local: backwardcomp
title: Backward compatibility
title: "About"
- sections:
- local: api/robots
title: Robots
- local: api/teleoperators
title: Teleoperators
- local: api/cameras
title: Cameras
- local: api/motors
title: Motors
- local: api/datasets
title: Datasets
- local: api/policies
title: Policies
- local: api/processor
title: Processors
- local: api/envs
title: Environments
- local: api/configs
title: Configuration
title: "API Reference"
-24
View File
@@ -1,24 +0,0 @@
# Cameras
Cameras supply the image observations a policy sees. Every backend — OpenCV, Intel RealSense, Reachy 2 —
implements the [`Camera`] interface, so swapping hardware does not change the code that reads frames.
See the [Cameras guide](../cameras) for choosing and configuring a camera, and
[Third-Party Cameras & Sensors](../third_party_sensors) for devices outside the core set.
## Camera
[[autodoc]] lerobot.cameras.Camera
- connect
- disconnect
- read
- async_read
- find_cameras
## CameraConfig
[[autodoc]] lerobot.cameras.CameraConfig
## make_cameras_from_configs
[[autodoc]] lerobot.cameras.make_cameras_from_configs
-27
View File
@@ -1,27 +0,0 @@
# Configuration
LeRobot configuration is plain dataclasses parsed by [draccus](https://github.com/dlwh/draccus), so every
field is settable from the CLI. [`TrainPipelineConfig`] is the top-level object for `lerobot-train`.
Polymorphic configs (policies, robots, environments) use `draccus.ChoiceRegistry`: a subclass registers
itself with `@register_subclass("name")` and is then selectable by that name on the command line.
## TrainPipelineConfig
[[autodoc]] lerobot.configs.train.TrainPipelineConfig
## PreTrainedConfig
[[autodoc]] lerobot.configs.PreTrainedConfig
## DatasetConfig
[[autodoc]] lerobot.configs.DatasetConfig
## EvalConfig
[[autodoc]] lerobot.configs.EvalConfig
## WandBConfig
[[autodoc]] lerobot.configs.WandBConfig
-23
View File
@@ -1,23 +0,0 @@
# Datasets
[`LeRobotDataset`] is the format every LeRobot script reads and writes. It is episode-aware, decodes video
observations on the fly, and round-trips to the Hugging Face Hub.
See [Using LeRobotDataset](../lerobot-dataset-v3) for the format and the common operations,
[Porting Large Datasets](../porting_datasets_v3) for migration, and [Tools](../tools) for the CLI.
## LeRobotDataset
[[autodoc]] lerobot.datasets.LeRobotDataset
## LeRobotDatasetMetadata
[[autodoc]] lerobot.datasets.LeRobotDatasetMetadata
## MultiLeRobotDataset
[[autodoc]] lerobot.datasets.MultiLeRobotDataset
## StreamingLeRobotDataset
[[autodoc]] lerobot.datasets.StreamingLeRobotDataset
-19
View File
@@ -1,19 +0,0 @@
# Environments
Simulation environments are configured through [`EnvConfig`] and built by [`make_env`]. Each subclass
declares its `gym_kwargs` and how to construct the vectorised environments.
See [Environments from the Hub](../envhub) for using published environments and
[Adding a New Benchmark](../adding_benchmarks) for contributing one.
## EnvConfig
[[autodoc]] lerobot.envs.EnvConfig
## make_env
[[autodoc]] lerobot.envs.make_env
## make_env_config
[[autodoc]] lerobot.envs.make_env_config
-23
View File
@@ -1,23 +0,0 @@
# Motors
`MotorsBus` is the low-level interface to a chain of servos on a serial bus. Robots use it to read positions
and write goal positions; you rarely touch it directly unless you are adding hardware.
See [Bring Your Own Hardware](../integrate_hardware) for adding a new bus, and
[Updating Feetech Firmware](../feetech) and [Damiao Motors and CAN Bus](../damiao) for device-specific notes.
## MotorsBus
[[autodoc]] lerobot.motors.motors_bus.MotorsBus
## Motor
[[autodoc]] lerobot.motors.Motor
## MotorCalibration
[[autodoc]] lerobot.motors.MotorCalibration
## MotorNormMode
[[autodoc]] lerobot.motors.MotorNormMode
-20
View File
@@ -1,20 +0,0 @@
# Policies
Every policy inherits [`PreTrainedPolicy`], which combines a `torch.nn.Module` with the Hub mixin, so any
policy can be pushed to and loaded from the Hugging Face Hub with the same two calls.
Each policy has its own guide with training recipes and results — [ACT](../act), [SmolVLA](../smolvla),
[π₀](../pi0), [π₀.₅](../pi05) and the rest are listed under Policies. To add one, see
[Adding a Policy](../bring_your_own_policies).
## PreTrainedPolicy
[[autodoc]] lerobot.policies.pretrained.PreTrainedPolicy
## PreTrainedConfig
[[autodoc]] lerobot.configs.PreTrainedConfig
## make_policy
[[autodoc]] lerobot.policies.factory.make_policy
-20
View File
@@ -1,20 +0,0 @@
# Processors
Processors are the data transformation layer between a robot, a dataset and a policy. A pipeline is a chain
of [`ProcessorStep`]s; each step declares how it transforms both the data and the feature contract.
See [Introduction to Robot Processors](../introduction_processors) for the concepts,
[Implement your own processor](../implement_your_own_processor) to write a step, and
[Debug your processor pipeline](../debug_processor_pipeline) when a pipeline misbehaves.
## ProcessorStep
[[autodoc]] lerobot.processor.pipeline.ProcessorStep
## DataProcessorPipeline
[[autodoc]] lerobot.processor.pipeline.DataProcessorPipeline
## PolicyProcessorPipeline
[[autodoc]] lerobot.processor.pipeline.PolicyProcessorPipeline
-147
View File
@@ -1,147 +0,0 @@
# Robots
Every robot in LeRobot implements the [`Robot`] interface: connect, read an observation, send an action,
disconnect. Writing a policy or a recording script against that interface means it works with any supported
arm without change.
This page is the generated reference. For wiring, calibration and first-run instructions, start with the
hardware guides — [SO-101](../so101), [LeKiwi](../lekiwi), [Hope Jr](../hope_jr), [Reachy 2](../reachy2),
[OpenArm](../openarm) — or [Imitation Learning for Robots](../il_robots) for the end-to-end workflow. To add
a robot of your own, see [Bring Your Own Hardware](../integrate_hardware).
## Robot
The abstract base class. Subclasses implement every method below; the contract described here is what a
policy or recording loop can rely on.
[[autodoc]] lerobot.robots.Robot
- connect
- disconnect
- configure
- calibrate
- get_observation
- send_action
- observation_features
- action_features
- is_connected
- is_calibrated
## RobotConfig
[[autodoc]] lerobot.robots.RobotConfig
## make_robot_from_config
[[autodoc]] lerobot.robots.make_robot_from_config
## SO-100 and SO-101 followers
`SO100Follower` and `SO101Follower` are aliases of the same `SOFollower` class; the two arms differ in their
configuration, not their control code. `SO100FollowerConfig` and `SO101FollowerConfig` are likewise aliases
of `SOFollowerRobotConfig`.
[[autodoc]] lerobot.robots.so_follower.SOFollower
- all
[[autodoc]] lerobot.robots.so_follower.SOFollowerRobotConfig
## BiSOFollower
Two SO followers driven as one bimanual robot.
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollower
- all
[[autodoc]] lerobot.robots.bi_so_follower.BiSOFollowerConfig
## KochFollower
[[autodoc]] lerobot.robots.koch_follower.KochFollower
- all
[[autodoc]] lerobot.robots.koch_follower.KochFollowerConfig
## LeKiwi
`LeKiwi` runs on the robot itself. `LeKiwiClient` is the host-side proxy that talks to it over the network
and presents the same [`Robot`] interface.
[[autodoc]] lerobot.robots.lekiwi.LeKiwi
- all
[[autodoc]] lerobot.robots.lekiwi.LeKiwiConfig
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClient
- all
[[autodoc]] lerobot.robots.lekiwi.LeKiwiClientConfig
## OpenArmFollower
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollower
- all
[[autodoc]] lerobot.robots.openarm_follower.OpenArmFollowerConfig
## BiOpenArmFollower
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollower
- all
[[autodoc]] lerobot.robots.bi_openarm_follower.BiOpenArmFollowerConfig
## OmxFollower
[[autodoc]] lerobot.robots.omx_follower.OmxFollower
- all
[[autodoc]] lerobot.robots.omx_follower.OmxFollowerConfig
## Reachy2Robot
[[autodoc]] lerobot.robots.reachy2.Reachy2Robot
- all
[[autodoc]] lerobot.robots.reachy2.Reachy2RobotConfig
## UnitreeG1
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1
- all
[[autodoc]] lerobot.robots.unitree_g1.UnitreeG1Config
## Hope Jr
The Hope Jr humanoid is exposed as two independent robots, an arm and a hand.
[[autodoc]] lerobot.robots.hope_jr.HopeJrArm
- all
[[autodoc]] lerobot.robots.hope_jr.HopeJrArmConfig
[[autodoc]] lerobot.robots.hope_jr.HopeJrHand
- all
[[autodoc]] lerobot.robots.hope_jr.HopeJrHandConfig
## RebotB601Follower
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601Follower
- all
[[autodoc]] lerobot.robots.rebot_b601_follower.RebotB601FollowerRobotConfig
## BiRebotB601Follower
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601Follower
- all
[[autodoc]] lerobot.robots.bi_rebot_b601_follower.BiRebotB601FollowerConfig
## EarthRoverMiniPlus
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlus
- all
[[autodoc]] lerobot.robots.earthrover_mini_plus.EarthRoverMiniPlusConfig
-30
View File
@@ -1,30 +0,0 @@
# Teleoperators
A teleoperator produces actions for a robot to follow — a leader arm, a gamepad, a keyboard, a phone. All of
them implement the [`Teleoperator`] interface, so a recording script written against it works with any input
device.
See [Phone teleoperation](../phone_teleop) and [Isaac Teleop](../isaac_teleop) for setup guides, and
[Imitation Learning for Robots](../il_robots) for the recording workflow.
## Teleoperator
[[autodoc]] lerobot.teleoperators.Teleoperator
- connect
- disconnect
- configure
- calibrate
- get_action
- send_feedback
- action_features
- feedback_features
- is_connected
- is_calibrated
## TeleoperatorConfig
[[autodoc]] lerobot.teleoperators.TeleoperatorConfig
## make_teleoperator_from_config
[[autodoc]] lerobot.teleoperators.make_teleoperator_from_config
+81 -16
View File
@@ -241,24 +241,89 @@ See the [Real-Time Chunking](./rtc) guide for details on tuning RTC parameters.
---
## Interactive Sessions
Add `--interactive=true` to drive the rollout from the terminal instead of starting immediately. Hardware connects and the policy loads as usual, but **the robot stays still until you type `/start`** — useful when you want to position the scene first, re-instruct the policy between attempts, or run several takes without paying the load time again.
```bash
lerobot-rollout \
--strategy.type=base \
--policy.path=${HF_USER}/my_smolvla_policy \
--robot.type=so100_follower \
--robot.port=/dev/ttyACM0 \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}}" \
--task="pick up the cube" \
--interactive=true
```
| Command | Action |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/start` | Start (or restart) the policy control loop |
| `/subtask <text>` | Change the instruction the policy follows, without stopping. No argument prints the current task. Only affects policies that condition on language (SmolVLA, π0/π0.5, and similar) |
| `/ask <question>` | Ask a supported policy text head about its latest view. The answer is generated in the background without pausing the session |
| `/reset` | Stop movement, return the robot to its startup position, and restore the `--task` instruction |
| `/stop` | End the session and run the normal shutdown routines |
| `/help` | List the commands |
```text
> /start
Rollout running — task 'pick up the cube'. /subtask <text> to change it, ...
> /subtask put the cube in the box
Task: 'pick up the cube' → 'put the cube in the box' (applies from the next policy inference)
> /ask where is the red cube?
Question queued: 'where is the red cube?' (the rollout keeps running)
[policy] The red cube is beside the bowl.
> /reset
Task restored to 'pick up the cube'
Resetting — returning the robot to its initial position...
Robot reset — holding at initial position. /start to run.
> /stop
```
`Ctrl-C` still shuts down as usual, and closing stdin (`Ctrl-D`, or the end of a piped script) ends the session — so a piped script must keep stdin open for the intended duration:
```bash
(printf '/start\n'; sleep 60; printf '/stop\n') | lerobot-rollout ... --interactive=true
```
**How `/subtask` reaches the policy.** The stdin reader publishes the new instruction to the inference engine, which picks it up on its own inference thread, so nothing is mutated across threads while the robot is moving. How quickly the behavior changes depends on the backend:
- **Sync** (`--inference.type=sync`) — precomputed chunk actions are dropped, so the new instruction applies on the very next control tick. Without this a chunking policy would keep executing up to `chunk_size` stale actions (seconds of the old behavior). Only the queued actions are discarded, so observation history and the rest of the episode state are preserved.
- **RTC** (`--inference.type=rtc`) — the next chunk is generated under the new instruction and merged over the previous chunk's leftover prefix, so the switch lands within one inference and the motion stays continuous. The queue is deliberately not cleared: that would leave the robot without commands for a full inference latency. (With blending turned off via `--inference.rtc.enabled=false` the queued chunk drains first, so the switch lands up to one chunk later.)
With `--use_torch_compile=true`, a switch whose instruction tokenizes to a different length can trigger a recompilation on the next forward pass, pausing inference for as long as the original warm-up took. Prefer leaving compilation off for sessions where you expect to re-instruct the policy often.
**How `/ask` runs without taking over the rollout.** During an active rollout, the inference engine caches the latest policy-ready observation, so the command reader never touches cameras, processors, or robot hardware. A single background worker sends that snapshot to the optional `PreTrainedPolicy.generate_text(..., kind=TextKind.VQA, user_text=question)` hook and prints the result when ready. Questions are independent turns; there is no conversation history, and a second question is rejected while one is running so stale image tensors cannot accumulate. WALL-OSS (`wall_x`) is the first policy implementing this hook; policies without a compatible text head report that `/ask` is unsupported.
Text and action calls share one policy safely: the engine gives a pending question priority after the current action inference finishes, while action inference uses a non-blocking gate. The hardware loop therefore keeps ticking and `/ask` never clears an action queue. RTC continues dispatching its buffered actions while text is decoded. Sync keeps the robot on its last commanded target until the policy is available again. Text generation still consumes model/GPU capacity, so response generation can reduce action freshness; RTC is preferred when uninterrupted action buffering matters.
`/stop` suppresses any late answer and gives an active decoder five seconds to finish cleanly. If it is stuck, hardware teardown continues rather than leaving the robot session open indefinitely; the daemon may retain its model/GPU resources until it returns.
**Console logs are muted while the session runs** so they don't interleave with what you're typing; they resume when it ends. A fatal inference error is still printed. Run without `--interactive` to watch the live log.
Interactive sessions currently require `--strategy.type=base`: the recording strategies finalize their dataset when their loop exits, so they cannot be restarted by `/start`, and their keyboard controls would compete for the same terminal.
---
## Common Flags
| Flag | Description | Default |
| --------------------------------- | ----------------------------------------------------------------- | ------- |
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
| `--robot.port` | Serial port for the robot | -- |
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
| `--fps` | Control loop frequency | 30 |
| `--duration` | Run time in seconds (0 = infinite) | 0 |
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
| `--task` | Task description (used when no dataset is provided) | -- |
| `--display_data` | Stream telemetry to Rerun visualization | false |
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
| `--interpolation_multiplier` | Action interpolation factor | 1 |
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
| `--resume` | Resume a previous recording session | false |
| `--play_sounds` | Vocal synthesis for events | true |
| Flag | Description | Default |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| `--policy.path` | **Required.** HF Hub model ID or local checkpoint path | -- |
| `--robot.type` | **Required.** Robot type (e.g. `so100_follower`, `koch_follower`) | -- |
| `--robot.port` | Serial port for the robot | -- |
| `--robot.cameras` | Camera configuration (JSON dict) | -- |
| `--fps` | Control loop frequency | 30 |
| `--duration` | Run time in seconds (0 = infinite) | 0 |
| `--device` | Torch device (`cpu`, `cuda`, `mps`) | auto |
| `--task` | Task description (used when no dataset is provided) | -- |
| `--display_data` | Stream telemetry to Rerun visualization | false |
| `--display_ip` / `--display_port` | Remote Rerun server address | -- |
| `--interpolation_multiplier` | Action interpolation factor | 1 |
| `--interactive` | Chat-style stdin session (see [Interactive Sessions](#interactive-sessions)); the robot stays idle until `/start`. Base strategy only | false |
| `--use_torch_compile` | Enable `torch.compile` for inference | false |
| `--resume` | Resume a previous recording session | false |
| `--play_sounds` | Vocal synthesis for events | true |
---
-287
View File
@@ -1,287 +0,0 @@
# Writing docstrings
LeRobot's API reference is generated directly from the docstrings in `src/lerobot/`. A docstring is not a
comment — it is the published documentation for that object, and the format below is what the renderer and
the CI checks parse.
This page is the contract. If you are adding or editing anything public in `src/lerobot/`, follow it.
> [!IMPORTANT]
> **An undocumented public method is an invisible one.** `[[autodoc]]` silently skips members that have no
> docstring — no warning, no error, it simply does not appear on the rendered page. Coverage and
> API-reference completeness are the same problem.
## The format in one example
Google section headers, Hugging Face type formatting. Both, not one or the other.
````python
def send_action(self, action: RobotAction, rate_hz: float = 30.0) -> RobotAction:
"""Command the robot to move to a target joint configuration.
Values are clipped by the configured maximum relative target before reaching the motors, so the
returned action may differ from the requested one.
Args:
action (`dict[str, float]`):
Target values keyed by motor name, e.g. `{"shoulder_pan.pos": 0.0}`. Keys must match the
robot's action features.
rate_hz (`float`, *optional*, defaults to `30.0`):
Control loop frequency.
Returns:
`dict[str, float]`: The action actually written to the motors after safety clipping.
Raises:
DeviceNotConnectedError: If the robot has not been connected.
Example:
```python
>>> from lerobot.robots.so_follower import SO101Follower, SO101FollowerConfig
>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0")) # doctest: +SKIP
>>> robot.connect() # doctest: +SKIP
>>> robot.send_action({"shoulder_pan.pos": 0.0}) # doctest: +SKIP
```
"""
````
Cross-references are omitted from the examples on this page — see [Cross-references](#cross-references) for
their syntax and why they cannot be shown inside a code block.
## Rules
### Sections
`Args:` · `Returns:` · `Raises:` · `Yields:` · `Example:` · `Note:`
In that order. No other section headers. A one-line summary comes first, then an optional free-form
description, then the sections.
### The `Args:` line is machine-parsed
```
name (`type`, *optional*, defaults to `X`):
Description, indented on its own line.
```
The `*optional*, defaults to` clause is **checked against the real signature default** by
`make check-docstrings`. It is not decorative — if you write a default that has drifted from the code, CI
fails. Omit the clause entirely for required parameters:
```python
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps the magnitude of the relative positional target vector. `None` disables clipping.
use_degrees (`bool`, *optional*, defaults to `True`):
Keep `True` for backward compatibility with existing policies and datasets.
```
Types go in backticks. Use `*optional*` with no `defaults to` when the default is `None` or is otherwise not
worth restating.
### `Returns:` is type-first
One indented line, type first, then a colon, then the description:
```python
Returns:
`dict[str, float]`: The action actually written to the motors after safety clipping.
```
`Yields:` takes the same shape.
### `**Attributes**:`, never `Attributes:`
doc-builder parses a bare `Attributes:` as a **synonym for `Parameters:`**, so your attributes get rendered
as constructor arguments. This is silent and wrong. Whenever the attributes differ from the constructor
parameters, use the bold form with a `--` separator:
```python
class Robot(abc.ABC):
"""The base abstract class for all LeRobot-compatible robots.
**Attributes**:
- **config_class** (`type[RobotConfig]`) -- The expected configuration class for this robot.
- **name** (`str`) -- The unique robot name used to identify this robot type.
"""
```
Note `--`, not `:`.
### Cross-references
Use doc-builder's bracket syntax: a square-bracketed backtick-quoted path. **Sphinx roles (`:pymeth:`,
`:pyattr:`) are not supported** and render as literal text on the page.
| Want | Write |
| ---------------------------- | ----------------------------------- |
| Class in the main package | &#91;`Robot`&#93; |
| Method, show the full path | &#91;`Robot.connect`&#93; |
| Method, show the bare name | &#91;`~Robot.connect`&#93; |
| Nested path | &#91;`~robots.Robot.connect`&#93; |
| Object in another HF library | &#91;`~accelerate.Accelerator`&#93; |
The `~` strips the path from the **link text only**; the link still resolves to the full path.
> [!NOTE]
> doc-builder resolves this syntax everywhere in a page — including inside fenced code blocks. That is why
> the docstring examples on this page use plain prose instead of cross-references: a code block containing
> one would render the resolved link rather than the syntax you need to type. In your own docstrings, use
> cross-references freely; this restriction only affects documentation _about_ the syntax.
### Callouts
Use GitHub-style blockquotes:
```markdown
> [!TIP]
> Call this once at startup — it takes about two seconds.
> [!WARNING]
> Torque is disabled on disconnect. The arm will drop if it is holding a load.
```
The `<Tip>` component is legacy per doc-builder; don't add new ones.
### Examples must be fenced
An example lives inside a fenced ` ```python ` block containing `>>> `. The fence is what makes it render
as a code block, and it is what the doctest preprocessor's regex looks for:
````python
Example:
```python
>>> from lerobot.robots.so_follower import SO101FollowerConfig
>>> cfg = SO101FollowerConfig(port="/dev/ttyACM0")
>>> cfg.use_degrees
True
```
````
> [!WARNING]
> An unfenced `>>>` is still collected — doctest finds prompts anywhere in a docstring. What you lose is the
> rendering, so it shows up as a wall of prose on the page. Every example needs the fence.
Every example either executes in CI or carries `# doctest: +SKIP`. Anything that touches hardware, a GPU, or
downloads from the Hub gets `+SKIP`:
````python
Example:
```python
>>> robot.connect() # doctest: +SKIP
>>> policy = ACTPolicy.from_pretrained("lerobot/act_aloha_sim_transfer_cube_human") # doctest: +SKIP
```
````
Add files containing runnable examples to `utils/documentation_tests.txt`.
Put examples on the three to five genuine entry points of a module. Examples on trivial accessors are noise.
## Three patterns you will hit constantly
### Config dataclasses
Configuration fields are historically documented with `#` comments above each field. **doc-builder cannot
see inline comments** — such a class renders with every field listed and not a single description. Move them
into an `Args:` block on the class docstring:
```python
@dataclass
class SOFollowerConfig:
"""Configuration for SO-family follower arms.
Args:
port (`str`):
Serial port the arm is connected to, e.g. `/dev/ttyACM0`.
max_relative_target (`float | dict[str, float]`, *optional*):
Caps the magnitude of the relative positional target vector. A scalar applies to all motors;
a dict maps motor name to a per-motor cap. `None` disables clipping.
use_degrees (`bool`, *optional*, defaults to `True`):
Keep `True` for backward compatibility with existing policies and datasets.
"""
port: str
max_relative_target: float | dict[str, float] | None = None
use_degrees: bool = True
```
> [!IMPORTANT]
> **doc-builder does not inherit docstrings from base classes.** LeRobot's registered config classes are
> often thin multiple-inheritance shims:
>
> ```python
> @RobotConfig.register_subclass("so101_follower")
> @dataclass
> class SOFollowerRobotConfig(RobotConfig, SOFollowerConfig):
> pass
> ```
>
> That class renders **every** field — including the ones it inherits — with no descriptions at all, no
> matter how well the bases are documented. The `Args:` block must live on the concrete class that
> `[[autodoc]]` names, and it must cover inherited fields too.
### Base class, then concrete subclass
The abstract base carries the canonical contract. Subclasses document only what deviates — port semantics,
calibration quirks, motor layout, supported feature keys. Do not copy the base contract into every subclass.
`Robot`, `Teleoperator`, `Camera`, `MotorsBus`, `ProcessorStep`, and `PreTrainedPolicy` all follow this
shape.
### Module-level aliases
Several public names are aliases rather than distinct classes:
```python
SO100FollowerConfig = SOFollowerRobotConfig
SO101FollowerConfig = SOFollowerRobotConfig
```
`[[autodoc]]` resolves the alias and renders the **canonical** class name, so a `## SO101FollowerConfig`
heading will show `class lerobot.robots.so_follower.SOFollowerRobotConfig` in the body. Document the
canonical class once, and mention the aliases in the page's prose rather than giving each alias its own
autodoc block.
## What not to document
- **Private members.** Anything starting with `_` is not part of the public API.
- **The type annotation restated as prose.** `port (`str`): A string.` adds nothing. Say what it is for.
- **Vendored upstream code.** `src/lerobot/policies/molmoact2/molmoact2_hf_model/` is vendored from
`transformers` and already carries upstream-style docstrings. Leave it alone — restyling it only creates
conflicts on the next sync. It is excluded from the API reference and from the docstring checks.
## How this is enforced
| Check | What it catches |
| ------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `make check-docstrings` | An `Args:` entry that doesn't match the signature; a documented default that has drifted from the real one |
| `make doctest` | Examples that no longer run |
| `make check-doctest-list` | Stale or unsorted entries in `utils/documentation_tests.txt` |
| `ruff` (`D` rules) | Google-convention style violations |
| `interrogate` | Docstring coverage falling below the current threshold |
| doc-builder | A `[[autodoc]]` path that points at something that doesn't exist — this breaks the docs build |
Run them together before opening a PR:
```bash
make check-docstrings && make doctest && pre-commit run --all-files
```
Then render the page and actually look at it:
```bash
doc-builder build lerobot docs/source/ --build_dir /tmp/doc-build
```
## Checklist
- [ ] Every public member you touched has a docstring.
- [ ] Every `Args:` entry matches the signature, including the `*optional*, defaults to` clause.
- [ ] `Returns:` is type-first on one indented line.
- [ ] No bare `Attributes:` — use `**Attributes**:` with `--` separators.
- [ ] No Sphinx roles — cross-references use &#91;`~module.Class.method`&#93;.
- [ ] Examples are inside a fenced ` ```python ` block, and either run in CI or carry `# doctest: +SKIP`.
- [ ] Config dataclass fields are in an `Args:` block on the concrete class, not `#` comments.
- [ ] The rendered page has been eyeballed.
+17 -64
View File
@@ -401,7 +401,7 @@ exclude = ["tests/artifacts/**/*.safetensors", "*_pb2.py", "*_pb2_grpc.py"]
# N: pep8-naming
# TODO: Uncomment rules when ready to use
select = [
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM", "D" #, "A", "S", "RUF"
"E", "W", "F", "I", "B", "C4", "T20", "N", "UP", "SIM" #, "A", "S", "D", "RUF"
]
ignore = [
"E501", # Line too long
@@ -411,53 +411,9 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"__init__.py" = ["F401", "F403", "E402", "D104"]
"__init__.py" = ["F401", "F403", "E402"]
# E402: conditional-import guards (TYPE_CHECKING / is_package_available) must precede the imports they protect
"src/lerobot/scripts/convert_dataset_v21_to_v30.py" = ["E402"]
# D (pydocstyle) is enabled globally, but only holds for code that has been converted to the docstring
# standard in docs/source/writing_docstrings.mdx. Every module below is still on the old style; each entry
# is deleted as that module is converted, and this block can be removed once it is empty.
#
# Not part of the API reference and not planned for conversion: tests, examples, benchmarks, templates,
# CI helper scripts and the packaging shim.
"tests/**" = ["D"]
"examples/**" = ["D"]
"benchmarks/**" = ["D"]
"scripts/**" = ["D"]
"setup.py" = ["D"]
"src/lerobot/templates/**" = ["D"]
# Vendored from transformers; keeps its upstream docstring style so syncs stay clean.
"src/lerobot/policies/molmoact2/molmoact2_hf_model/**" = ["D"]
# Awaiting conversion, one PR per module.
"src/lerobot/annotations/**" = ["D"]
"src/lerobot/async_inference/**" = ["D"]
"src/lerobot/cameras/**" = ["D"]
"src/lerobot/common/**" = ["D"]
"src/lerobot/configs/**" = ["D"]
"src/lerobot/data_processing/**" = ["D"]
"src/lerobot/datasets/**" = ["D"]
"src/lerobot/distributed/**" = ["D"]
"src/lerobot/envs/**" = ["D"]
"src/lerobot/jobs/**" = ["D"]
"src/lerobot/model/**" = ["D"]
"src/lerobot/motors/**" = ["D"]
"src/lerobot/optim/**" = ["D"]
"src/lerobot/policies/**" = ["D"]
"src/lerobot/processor/**" = ["D"]
"src/lerobot/rewards/**" = ["D"]
"src/lerobot/rl/**" = ["D"]
"src/lerobot/robots/**" = ["D"]
"src/lerobot/rollout/**" = ["D"]
"src/lerobot/scripts/**" = ["D"]
"src/lerobot/teleoperators/**" = ["D"]
"src/lerobot/transforms/**" = ["D"]
"src/lerobot/transport/**" = ["D"]
"src/lerobot/utils/**" = ["D"]
"src/lerobot/lerobot_types.py" = ["D"]
# Package root: two one-line docstring fixes land with the docstring PR.
"src/lerobot/__init__.py" = ["D"]
"src/lerobot/__version__.py" = ["D"]
[tool.ruff.lint.isort]
combine-as-imports = true
known-first-party = ["lerobot"]
@@ -501,24 +457,21 @@ default.extend-ignore-identifiers-re = [
"seperated_timestep",
]
# Docstring coverage gate. `fail-under` is a RATCHET, not a target: it is set just below the currently
# measured coverage so it passes today, and is raised in the same PR that documents a module. Never set it
# to a value that fails on main. The destination is 100; see docs/source/writing_docstrings.mdx.
[tool.interrogate]
ignore-init-module = true
ignore-init-method = true
ignore-nested-functions = false
ignore-magic = false
ignore-semiprivate = false
ignore-private = false
ignore-property-decorators = false
ignore-module = false
ignore-setters = false
fail-under = 52
output-format = "term-missing"
color = true
paths = ["src/lerobot"]
exclude = ["src/lerobot/policies/molmoact2/molmoact2_hf_model"]
# TODO: Uncomment when ready to use
# [tool.interrogate]
# ignore-init-module = true
# ignore-init-method = true
# ignore-nested-functions = false
# ignore-magic = false
# ignore-semiprivate = false
# ignore-private = false
# ignore-property-decorators = false
# ignore-module = false
# ignore-setters = false
# fail-under = 80
# output-format = "term-missing"
# color = true
# paths = ["src/lerobot"]
# TODO: Enable mypy gradually module by module across multiple PRs
# Uncomment [tool.mypy] first, then uncomment individual module overrides as they get proper type annotations
+2
View File
@@ -31,6 +31,7 @@ from .types import (
PipelineFeatureType,
PolicyFeature,
RTCAttentionSchedule,
TextKind,
)
from .video import (
DEFAULT_DEPTH_UNIT,
@@ -54,6 +55,7 @@ __all__ = [
"PipelineFeatureType",
"PolicyFeature",
"RTCAttentionSchedule",
"TextKind",
# Config classes
"DatasetRecordConfig",
"DatasetConfig",
+6
View File
@@ -67,6 +67,12 @@ class PreTrainedConfig(draccus.ChoiceRegistry, HubMixin, abc.ABC): # type: igno
# Whether the policy employed PEFT for training.
use_peft: bool = False
# Decoding defaults for policies that implement `generate_text`. They live
# in config.json so a text head uses the settings it was trained/evaluated
# with; policy-specific decoding knobs belong on the concrete config.
text_temperature: float = 0.0 # 0.0 = greedy; > 0 enables sampling
text_top_p: float = 1.0
push_to_hub: bool = True # type: ignore[assignment] # TODO: use a different name to avoid override
repo_id: str | None = None
+7
View File
@@ -31,6 +31,13 @@ class PipelineFeatureType(str, Enum):
OBSERVATION = "OBSERVATION"
class TextKind(str, Enum):
"""Text-generation requests understood by interactive policy hooks."""
SUBTASK = "subtask"
VQA = "vqa"
class NormalizationMode(str, Enum):
MIN_MAX = "MIN_MAX"
MEAN_STD = "MEAN_STD"
+47 -1
View File
@@ -28,7 +28,8 @@ from huggingface_hub.errors import HfHubHTTPError
from safetensors.torch import load_model as load_model_as_safetensor
from torch import Tensor, nn
from lerobot.configs import PreTrainedConfig
from lerobot.configs import PreTrainedConfig, TextKind
from lerobot.utils.constants import ACTION
from lerobot.utils.device_utils import resolve_safetensors_device
from lerobot.utils.hub import HubMixin
from lerobot.utils.import_utils import _peft_available, require_package
@@ -210,6 +211,51 @@ class PreTrainedPolicy(nn.Module, HubMixin, abc.ABC):
"""
raise NotImplementedError
def drop_queued_actions(self) -> None:
"""Discard actions precomputed by earlier ``select_action`` calls.
Chunking policies answer most control ticks from a queue filled by an
earlier forward pass, so a mid-episode change to the conditioning —
e.g. a new language instruction — would otherwise only take effect
once that queue drains (up to ``chunk_size`` ticks). Dropping the
queue forces a fresh forward pass on the next ``select_action``.
Unlike :meth:`reset` this keeps the rest of the episode state (e.g.
observation history), so it does not perturb policies that condition
on it. Call it from the thread that calls ``select_action``: it
mutates the same queues that thread pops from.
Policies that keep no action queue inherit a no-op.
"""
queues = getattr(self, "_queues", None)
if isinstance(queues, dict) and ACTION in queues:
queues[ACTION].clear()
action_queue = getattr(self, "_action_queue", None)
if action_queue is not None:
action_queue.clear()
def supports_text_generation(self) -> bool:
"""Whether this policy implements the optional :meth:`generate_text` hook."""
return type(self).generate_text is not PreTrainedPolicy.generate_text
def generate_text(
self,
batch: dict[str, Tensor],
*,
kind: TextKind = TextKind.SUBTASK,
user_text: str | None = None,
) -> str:
"""Generate one string from a policy's optional language head.
Interactive rollout calls this with a policy-ready observation batch.
Implementations must treat the batch as read-only and avoid mutating
action queues or episode state: text generation runs on a background
worker while the control loop remains active.
"""
raise NotImplementedError(
f"{type(self).__name__} has no text head. Implement `generate_text` to support /ask."
)
def supports_rtc(self) -> bool:
"""Whether this policy implements Real-Time Chunking inference semantics."""
return False
@@ -52,6 +52,7 @@ from torch.nn import CrossEntropyLoss
from torchvision.transforms import InterpolationMode
from torchvision.transforms.v2 import functional as tv_functional
from lerobot.configs import TextKind
from lerobot.utils.constants import ACTION, OBS_STATE
from lerobot.utils.import_utils import (
_wallx_deps_available,
@@ -107,6 +108,7 @@ else:
from .utils import (
get_wallx_normal_text,
img_key_mapping,
preprocesser_call,
process_grounding_points,
replace_action_token,
@@ -1585,6 +1587,25 @@ class Qwen2_5_VLMoEForAction(_Qwen2_5_VLForAction_Base): # noqa: N801
- Handles special cases for input_embeds, generation methods, and GPU synchronization
- Manages vision inputs to avoid unnecessary forward passes
"""
if cache_position is None:
past_length = 0
if past_key_values is not None and hasattr(past_key_values, "get_seq_length"):
past_length = int(past_key_values.get_seq_length())
input_length = input_ids.shape[1]
end = input_length if input_length > past_length else past_length + input_length
cache_position = torch.arange(
past_length,
end,
dtype=torch.long,
device=input_ids.device,
)
if cache_position.numel() == 0:
cache_position = torch.arange(
input_length,
dtype=torch.long,
device=input_ids.device,
)
# Initialize MoE token types if not provided
if moe_token_types is None:
moe_token_types = torch.zeros_like(
@@ -1851,6 +1872,23 @@ class WallXPolicy(PreTrainedPolicy):
"""Get parameters for optimization."""
return self.parameters()
@staticmethod
def _observation_prompt(img_keys: list[str]) -> str:
prompt = "Observation:"
for label in img_key_mapping(img_keys):
prompt += f" {label}: <|vision_start|><|image_pad|><|vision_end|>"
return prompt
def _format_text_prompt(self, instruction: str, kind: str, img_keys: list[str]) -> str:
if kind == TextKind.SUBTASK:
instruction = f"{instruction}\nPredict the next action in language."
return (
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
f"<|im_start|>user\n{self._observation_prompt(img_keys)}\n"
f"Instruction: {instruction}<|im_end|>\n"
"<|im_start|>assistant\n"
)
def preprocess_inputs(
self,
batch: dict[str, Any],
@@ -2080,6 +2118,118 @@ class WallXPolicy(PreTrainedPolicy):
return loss, loss_dict
def _build_text_inputs(
self,
batch: dict[str, Any],
*,
kind: str,
user_text: str | list[str] | None,
) -> BatchFeature:
batch_size = batch[OBS_STATE].shape[0]
img_keys = [key for key in self.config.image_features if key in batch]
if not img_keys:
raise ValueError("Wall-X text generation requires at least one image feature.")
image_inputs, dimensions_by_key = _prepare_wall_x_image_inputs(batch, img_keys)
orig_height, orig_width, resized_height, resized_width = dimensions_by_key[img_keys[-1]]
tasks = batch["task"] if isinstance(batch["task"], list) else [batch["task"]] * batch_size
if user_text is None:
instructions = tasks
elif isinstance(user_text, str):
instructions = [user_text] * batch_size
elif len(user_text) == batch_size:
instructions = user_text
else:
raise ValueError(f"Expected one text prompt for each of the {batch_size} samples.")
texts = [
process_grounding_points(
self._format_text_prompt(str(instruction), kind, img_keys),
orig_height,
orig_width,
resized_height,
resized_width,
MODEL_TYPE,
)
for instruction in instructions
]
inputs = preprocesser_call(
processor=self.model.processor,
text=texts,
images=image_inputs,
videos=None,
device=batch[OBS_STATE].device,
padding=True,
truncation=True,
return_tensors="pt",
max_length=TOKENIZER_MAX_LENGTH,
)
inputs.pop("labels", None)
inputs["moe_token_types"] = torch.zeros_like(inputs.input_ids, dtype=torch.bool)
for key, value in inputs.items():
if isinstance(value, torch.Tensor):
inputs[key] = value.to(batch[OBS_STATE].device)
return inputs
@torch.no_grad()
def generate_text(
self,
batch: dict[str, Tensor],
*,
kind: TextKind = TextKind.SUBTASK,
user_text: str | None = None,
) -> str:
"""Generate one grounded language response from the WALL-OSS VLM."""
outputs = self.generate_texts(
batch,
kind=kind,
user_text=user_text,
temperature=self.config.text_temperature,
top_p=self.config.text_top_p,
)
if len(outputs) != 1:
raise ValueError(f"Interactive rollout expected one Wall-X output, got {len(outputs)}.")
return outputs[0]
@torch.no_grad()
def generate_texts(
self,
batch: dict[str, Any],
*,
kind: TextKind = TextKind.VQA,
user_text: str | list[str] | None = None,
max_new_tokens: int = 100,
min_new_tokens: int = 0,
temperature: float = 0.0,
top_p: float = 1.0,
) -> list[str]:
"""Generate grounded Wall-X text for one or more observations."""
self.eval()
if kind not in {TextKind.VQA, TextKind.SUBTASK}:
raise ValueError("Unsupported Wall-X text kind.")
inputs = self._build_text_inputs(batch, kind=kind, user_text=user_text)
prompt_length = inputs.input_ids.shape[1]
sampling = temperature > 0
generation_kwargs: dict[str, Any] = {
"max_new_tokens": max_new_tokens,
"min_new_tokens": min_new_tokens,
"do_sample": sampling,
"eos_token_id": self.model.processor.tokenizer.eos_token_id,
"pad_token_id": self.model.processor.tokenizer.pad_token_id,
"use_cache": True,
}
if sampling:
generation_kwargs.update(temperature=temperature, top_p=top_p)
output_ids = self.model.generate(**inputs, **generation_kwargs)
return [
value.strip()
for value in self.model.processor.tokenizer.batch_decode(
output_ids[:, prompt_length:],
skip_special_tokens=True,
clean_up_tokenization_spaces=True,
)
]
@torch.no_grad()
def predict_action_chunk(self, batch: dict[str, Tensor]) -> Tensor:
"""Predict action chunk for evaluation."""
+18 -2
View File
@@ -47,6 +47,15 @@ from .inference import (
SyncInferenceEngine,
create_inference_engine,
)
from .interactive import (
InteractiveCommand,
InteractiveSession,
LinkedEvent,
StdinCommandListener,
TextQueryRequest,
TextQueryWorker,
parse_command,
)
from .strategies import (
BaseStrategy,
DAggerStrategy,
@@ -65,13 +74,18 @@ __all__ = [
"DAggerStrategy",
"DAggerStrategyConfig",
"DatasetContext",
"EpisodicStrategy",
"EpisodicStrategyConfig",
"HardwareContext",
"HighlightStrategy",
"HighlightStrategyConfig",
"EpisodicStrategy",
"EpisodicStrategyConfig",
"InferenceEngine",
"InferenceEngineConfig",
"InteractiveCommand",
"InteractiveSession",
"LinkedEvent",
"TextQueryRequest",
"TextQueryWorker",
"PolicyContext",
"ProcessorContext",
"RTCInferenceConfig",
@@ -83,9 +97,11 @@ __all__ = [
"RuntimeContext",
"SentryStrategy",
"SentryStrategyConfig",
"StdinCommandListener",
"SyncInferenceConfig",
"SyncInferenceEngine",
"build_rollout_context",
"create_inference_engine",
"create_strategy",
"parse_command",
]
+18
View File
@@ -239,6 +239,13 @@ class RolloutConfig:
# Runtime
fps: float = 30.0
duration: float = 0.0 # 0 = infinite (24/7 mode)
# Interactive session: control the rollout from stdin with chat-style
# commands (/start, /subtask <text>, /ask <question>, /reset, /stop) while hardware and
# policy stay warm. The robot does not move until /start is received,
# `/subtask` re-instructs the policy mid-run, and console logs are muted
# while the session runs so they don't interleave with the prompt.
# Currently limited to --strategy.type=base.
interactive: bool = False
interpolation_multiplier: int = 1
device: str | None = None
task: str = ""
@@ -294,6 +301,17 @@ class RolloutConfig:
"Base strategy does not record data. Use sentry, highlight, or dagger for recording."
)
# Interactive mode drives strategy.run() in restartable segments and reads
# commands from stdin. Recording strategies are excluded for now: their
# run() loops finalize the dataset on exit (so they cannot be restarted)
# and their keyboard listeners read the same terminal as the command
# prompt.
if self.interactive and not isinstance(self.strategy, BaseStrategyConfig):
raise ValueError(
f"--interactive=true currently supports only --strategy.type=base "
f"(got '{self.strategy.type}')."
)
# Sentry MUST use streaming encoding to avoid disk I/O blocking the control loop
if (
isinstance(self.strategy, SentryStrategyConfig)
+169
View File
@@ -22,9 +22,20 @@ or asynchronously in a background thread (RTC).
from __future__ import annotations
import abc
import logging
from copy import copy
from threading import Event, Lock
from typing import TYPE_CHECKING
import torch
from lerobot.configs import TextKind
if TYPE_CHECKING:
from lerobot.policies.pretrained import PreTrainedPolicy
logger = logging.getLogger(__name__)
class InferenceEngine(abc.ABC):
"""Abstract backend for producing actions during rollout.
@@ -47,12 +58,165 @@ class InferenceEngine(abc.ABC):
backends always compute from ``obs_frame``; async backends ignore
it (they receive observations via ``notify_observation``).
Task
----
``task`` / ``set_task`` hold the language instruction the policy is
conditioned on. ``set_task`` is safe to call from any thread (the
interactive session's ``/subtask`` command calls it from its stdin
reader); subclasses pick the new value up on their own inference
thread via :meth:`_take_task`, so no policy state is ever mutated
across threads.
Text queries
------------
Backends publish their latest policy-ready observation through
:meth:`_publish_text_observation`. The interactive ``/ask`` worker takes
a shallow snapshot and calls :meth:`generate_text`; an engine-owned gate
prevents language decoding from racing an action-model call. Action
inference uses a non-blocking acquire so the hardware loop keeps ticking
while a text response is being decoded.
Optional hooks
--------------
``notify_observation`` / ``pause`` / ``resume`` have a no-op default
so rollout strategies can invoke them unconditionally.
Subclasses must call ``super().__init__(task=..., policy=...)``; the task
holder and optional text-generation plumbing are set up there.
"""
def __init__(self, task: str = "", policy: PreTrainedPolicy | None = None) -> None:
self._task = task
self._task_changed = False
self._task_lock = Lock()
self._policy = policy
self._text_observation: dict | None = None
self._text_observation_lock = Lock()
self._text_observation_publication_enabled = True
# A text query gets priority after the current action inference ends.
# Action backends never block on this lock: they return "no action yet"
# and let the control loop keep servicing hardware at its normal rate.
self._policy_call_lock = Lock()
self._text_query_pending = Event()
self._text_query_serial_lock = Lock()
# ------------------------------------------------------------------
# Task (language instruction)
# ------------------------------------------------------------------
@property
def task(self) -> str:
"""The language instruction currently conditioning inference."""
with self._task_lock:
return self._task
def set_task(self, task: str) -> bool:
"""Set the instruction used from the next inference onwards.
Callable from any thread. Returns ``True`` when the value
actually changed, so callers can report no-op switches.
"""
with self._task_lock:
if task == self._task:
return False
previous, self._task = self._task, task
self._task_changed = True
logger.info("Task changed: '%s' -> '%s'", previous, task)
return True
def _take_task(self) -> tuple[str, bool]:
"""Read the task and whether it changed since the last read.
Call from the thread that runs inference: the "changed" edge is
consumed here so the backend can drop actions precomputed under
the previous instruction before using the new one.
"""
with self._task_lock:
changed, self._task_changed = self._task_changed, False
return self._task, changed
def _discard_task_change(self) -> None:
"""Drop a pending task-change edge, e.g. from ``reset`` (state is already cleared)."""
with self._task_lock:
self._task_changed = False
# ------------------------------------------------------------------
# Optional text generation (interactive /ask)
# ------------------------------------------------------------------
def supports_text_generation(self) -> bool:
"""Whether the attached policy implements the optional text hook."""
return self._policy is not None and self._policy.supports_text_generation()
def _publish_text_observation(self, observation: dict) -> None:
"""Cache a policy-ready observation for a future background query."""
with self._text_observation_lock:
if self._text_observation_publication_enabled:
self._text_observation = copy(observation)
def invalidate_text_observation(self) -> None:
"""Discard the cached view and reject publications until the next reset.
``/reset`` calls this before its deferred main-thread homing begins.
Keeping publication disabled matters because action inference may
already be in flight and otherwise republish the pre-reset scene.
"""
with self._text_observation_lock:
self._text_observation = None
self._text_observation_publication_enabled = False
def _reset_text_observation(self) -> None:
"""Clear the cached view and allow the next control segment to publish."""
with self._text_observation_lock:
self._text_observation = None
self._text_observation_publication_enabled = True
def snapshot_text_observation(self) -> dict | None:
"""Return the latest policy-ready view without touching robot hardware."""
with self._text_observation_lock:
if self._text_observation is None:
return None
observation = copy(self._text_observation)
# A /subtask may have arrived since this visual observation was cached.
# Keep the image/state snapshot but pair it with the latest instruction.
observation["task"] = self.task
return observation
def generate_text(
self,
observation: dict,
*,
kind: TextKind,
user_text: str | None = None,
) -> str:
"""Run one policy text query outside the hardware loop.
Only one query is admitted at a time. Setting the pending edge before
acquiring the shared policy gate prevents a busy action backend from
repeatedly winning the lock and starving the question.
"""
if self._policy is None or not self._policy.supports_text_generation():
raise NotImplementedError("This policy does not support text generation.")
with self._text_query_serial_lock:
self._text_query_pending.set()
try:
with self._policy_call_lock, torch.inference_mode():
return self._policy.generate_text(observation, kind=kind, user_text=user_text)
finally:
self._text_query_pending.clear()
def _try_begin_action_inference(self) -> bool:
"""Acquire policy ownership without blocking the hardware loop."""
if self._text_query_pending.is_set():
return False
return self._policy_call_lock.acquire(blocking=False)
def _end_action_inference(self) -> None:
"""Release policy ownership acquired by :meth:`_try_begin_action_inference`."""
self._policy_call_lock.release()
@abc.abstractmethod
def start(self) -> None:
"""Initialise the backend."""
@@ -87,3 +251,8 @@ class InferenceEngine(abc.ABC):
def failed(self) -> bool:
"""True if an unrecoverable error occurred in the backend."""
return False
@property
def failure_traceback(self) -> str | None:
"""Formatted traceback of the unrecoverable error, when ``failed`` is True."""
return None
+69 -10
View File
@@ -124,13 +124,12 @@ class RTCInferenceEngine(InferenceEngine):
rtc_queue_threshold: int = 30,
shutdown_event: Event | None = None,
) -> None:
self._policy = policy
super().__init__(task=task, policy=policy)
self._preprocessor = preprocessor
self._postprocessor = postprocessor
self._robot = robot_wrapper
self._rtc_config = rtc_config
self._hw_features = hw_features
self._task = task
self._fps = fps
self._device = device or "cpu"
self._use_torch_compile = use_torch_compile
@@ -140,10 +139,14 @@ class RTCInferenceEngine(InferenceEngine):
self._action_queue: ActionQueue | None = None
self._obs_holder: dict[str, Any] = {}
self._obs_lock = Lock()
# Bumped by reset() (under _obs_lock) so chunks whose inference started
# before a reset are discarded instead of merged into the fresh queue.
self._reset_epoch = 0
self._policy_active = Event()
self._compile_warmup_done = Event()
self._shutdown_event = Event()
self._rtc_error = Event()
self._failure_traceback: str | None = None
self._global_shutdown_event = shutdown_event
self._rtc_thread: Thread | None = None
@@ -190,6 +193,15 @@ class RTCInferenceEngine(InferenceEngine):
"""True if the RTC background thread exited due to an unrecoverable error."""
return self._rtc_error.is_set()
@property
def failure_traceback(self) -> str | None:
"""Traceback captured when the RTC thread died (see ``failed``).
Kept on the engine so consumers that mute console logging (the
interactive session) can still surface the fatal error.
"""
return self._failure_traceback
@property
def action_queue(self) -> ActionQueue | None:
"""The shared action queue between the RTC thread and the main loop."""
@@ -235,13 +247,31 @@ class RTCInferenceEngine(InferenceEngine):
self._policy_active.set()
def reset(self) -> None:
"""Reset the policy, processors, and action queue."""
"""Reset the policy, processors, and action queue.
Call while the engine is paused (both DAgger transitions and the
interactive session do): the RTC thread may still be finishing an
inference started before the pause, so ``reset`` also drops the last
published observation — it can be arbitrarily stale by the time the
engine resumes (e.g. the robot was returned to its initial position
in the meantime), and a chunk computed from it would jerk the robot
toward the old pose — and bumps the reset epoch so any in-flight
chunk is discarded instead of merged into the cleared queue.
"""
logger.info("Resetting RTC inference state (policy + processors + queue)")
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
with self._policy_call_lock:
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
if self._action_queue is not None:
self._action_queue.clear()
with self._obs_lock:
self._obs_holder["obs"] = None
self._reset_epoch += 1
self._reset_text_observation()
# The queue was just cleared, so a pending task change has nothing
# stale left to blend against.
self._discard_task_change()
# ------------------------------------------------------------------
# Action production (called from main thread)
@@ -281,11 +311,18 @@ class RTCInferenceEngine(InferenceEngine):
queue = self._action_queue
with self._obs_lock:
obs = self._obs_holder.get("obs")
epoch_before = self._reset_epoch
if queue is None or obs is None:
time.sleep(_RTC_IDLE_SLEEP_S)
continue
if queue.qsize() <= self._rtc_queue_threshold:
if not self._try_begin_action_inference():
# A background /ask is using the policy. The control
# thread can keep draining the already-produced RTC
# actions; do not start another model call meanwhile.
time.sleep(_RTC_IDLE_SLEEP_S)
continue
try:
current_time = time.perf_counter()
idx_before = queue.get_action_index()
@@ -294,13 +331,27 @@ class RTCInferenceEngine(InferenceEngine):
latency = latency_tracker.max()
delay = math.ceil(latency / time_per_chunk) if latency else 0
task, task_changed = self._take_task()
if task_changed:
# No queue flush on purpose: dropping the queued
# actions would leave the robot without commands for
# a full inference latency. With RTC blending on
# (the default) this chunk — already conditioned on
# the new instruction — is merged over the previous
# chunk's leftover prefix, so the switch lands within
# one inference and the transition stays continuous.
# With blending disabled the queue drains first, so
# it lands up to one chunk later.
logger.info("Task changed to '%s' — applied from this chunk on", task)
obs_batch = build_dataset_frame(self._hw_features, obs, prefix="observation")
obs_batch = prepare_observation_for_inference(
obs_batch, policy_device, self._task, self._robot.robot_type
obs_batch, policy_device, task, self._robot.robot_type
)
obs_batch["task"] = [self._task]
obs_batch["task"] = [task]
preprocessed = self._preprocessor(obs_batch)
self._publish_text_observation(preprocessed)
if prev_actions is not None and self._relative_step is not None:
# Rebase against the raw cached state so the leftover tail stays in
@@ -339,7 +390,12 @@ class RTCInferenceEngine(InferenceEngine):
else:
latency_tracker.add(new_latency)
queue.merge(original, processed, new_delay, idx_before)
with self._obs_lock:
epoch_unchanged = epoch_before == self._reset_epoch
if epoch_unchanged:
queue.merge(original, processed, new_delay, idx_before)
else:
logger.info("Discarding action chunk computed before an engine reset")
if (
is_warmup
@@ -364,12 +420,15 @@ class RTCInferenceEngine(InferenceEngine):
# Persistent failure: stop retrying and propagate shutdown.
raise
time.sleep(_RTC_ERROR_RETRY_DELAY_S)
finally:
self._end_action_inference()
else:
time.sleep(_RTC_IDLE_SLEEP_S)
except Exception as e:
self._failure_traceback = traceback.format_exc()
logger.error("Fatal error in RTC thread: %s", e)
logger.error(traceback.format_exc())
logger.error(self._failure_traceback)
self._rtc_error.set()
# Unblock any warmup waiters so the main loop doesn't spin forever
self._compile_warmup_done.set()
+50 -25
View File
@@ -65,12 +65,11 @@ class SyncInferenceEngine(InferenceEngine):
device: str | None,
robot_type: str,
) -> None:
self._policy = policy
super().__init__(task=task, policy=policy)
self._preprocessor = preprocessor
self._postprocessor = postprocessor
self._dataset_features = dataset_features
self._ordered_action_keys = ordered_action_keys
self._task = task
self._device = torch.device(device or "cpu")
self._robot_type = robot_type
logger.info(
@@ -90,33 +89,59 @@ class SyncInferenceEngine(InferenceEngine):
def reset(self) -> None:
"""Reset the policy and pre/post-processors."""
logger.info("Resetting sync inference state (policy + processors)")
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
with self._policy_call_lock:
self._policy.reset()
self._preprocessor.reset()
self._postprocessor.reset()
self._reset_text_observation()
# The policy was just reset, so a pending task change has nothing
# stale left to flush.
self._discard_task_change()
def get_action(self, obs_frame: dict | None) -> torch.Tensor | None:
"""Run the full inference pipeline on ``obs_frame`` and return an action tensor."""
if obs_frame is None:
return None
# Shallow copy is intentional: the caller (`send_next_action`) builds
# ``obs_frame`` fresh per tick via ``build_dataset_frame``, so the
# tensor/array values are not shared with any other reader.
observation = copy(obs_frame)
autocast_ctx = (
torch.autocast(device_type=self._device.type)
if self._device.type == "cuda" and self._policy.config.use_amp
else nullcontext()
)
with torch.inference_mode(), autocast_ctx:
observation = prepare_observation_for_inference(
observation, self._device, self._task, self._robot_type
if not self._try_begin_action_inference():
# A background /ask owns (or is waiting for) the policy. Do not
# block the control thread; the robot keeps executing its last
# dispatched target until action inference becomes available.
return None
try:
# Shallow copy is intentional: the caller (`send_next_action`) builds
# ``obs_frame`` fresh per tick via ``build_dataset_frame``, so the
# tensor/array values are not shared with any other reader.
observation = copy(obs_frame)
autocast_ctx = (
torch.autocast(device_type=self._device.type)
if self._device.type == "cuda" and self._policy.config.use_amp
else nullcontext()
)
observation = self._preprocessor(observation)
action = self._policy.select_action(observation)
action = self._postprocessor(action)
action_tensor = action.squeeze(0).cpu()
task, task_changed = self._take_task()
with torch.inference_mode(), autocast_ctx:
if task_changed:
# Chunking policies serve actions from an internal queue filled
# under the previous instruction (up to chunk_size ticks of stale
# behavior), so drop them and let the new instruction take effect
# on this very tick. Deliberately narrower than ``policy.reset``:
# observation history and other episode state are kept, so a
# policy that conditions on them (and one that ignores the task
# entirely) sees no discontinuity. Safe to mutate here — this is
# the thread that calls ``select_action``.
logger.info("Task changed to '%s' — dropping precomputed actions", task)
self._policy.drop_queued_actions()
observation = prepare_observation_for_inference(
observation, self._device, task, self._robot_type
)
observation = self._preprocessor(observation)
self._publish_text_observation(observation)
action = self._policy.select_action(observation)
action = self._postprocessor(action)
action_tensor = action.squeeze(0).cpu()
# Reorder to match dataset action ordering so the caller can treat
# the returned tensor uniformly across backends.
action_dict = make_robot_action(action_tensor, self._dataset_features)
return torch.tensor([action_dict[k] for k in self._ordered_action_keys])
# Reorder to match dataset action ordering so the caller can treat
# the returned tensor uniformly across backends.
action_dict = make_robot_action(action_tensor, self._dataset_features)
return torch.tensor([action_dict[k] for k in self._ordered_action_keys])
finally:
self._end_action_inference()
+782
View File
@@ -0,0 +1,782 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Interactive rollout session: chat-style stdin commands for ``lerobot-rollout``.
Enabled with ``--interactive=true``, this module lets the operator control a
rollout from the terminal while hardware and policy stay connected and warm:
/start start (or restart) the policy control loop
/subtask <text> change the instruction the policy follows, mid-run
/ask <question> ask the policy about the latest view without stopping
/reset stop movement, return the robot to its initial position,
and restore the instruction passed on the command line
/stop end the session and run the normal shutdown routines
/help show the available commands
Threading model (mirrors the DAgger events pattern): a daemon
:class:`StdinCommandListener` thread reads lines and only ever publishes
thread-safe state — flags for the session loop, and the instruction string
via :meth:`InferenceEngine.set_task`; it never touches hardware, and never
mutates policy state (the engine applies a task change on its own inference
thread). ``/ask`` snapshots the latest policy-ready observation and hands it
to one background text-query worker; the worker never reads robot hardware.
The :class:`InteractiveSession` driver runs on the main thread and executes
``strategy.run(ctx)`` in *segments*: each ``/start`` begins a segment, and
``/reset`` / ``/stop`` end it by setting the session's :class:`LinkedEvent`,
which every strategy control loop already polls as
``ctx.runtime.shutdown_event``. Real shutdown signals (SIGINT/SIGTERM)
propagate through the linked event's parent, so Ctrl-C behaves exactly as in
non-interactive runs.
While the session runs, console log handlers are muted (including
non-propagating library loggers like ``transformers``) and Python warnings
are suppressed, so system output does not interleave with the chat prompt;
only the session's own output is shown. File log handlers are unaffected,
and console logging resumes when the session ends (so teardown logs are
visible). A fatal inference-engine error is still surfaced: the session
prints the engine's captured traceback. Run without ``--interactive`` to
see the full live log output.
The command table is intentionally a name → (handler, argument hint, help)
mapping so further commands can be registered without restructuring the
parser, the help output, or the session loop.
"""
from __future__ import annotations
import logging
import os
import select
import sys
import time
import warnings
from collections.abc import Callable
from dataclasses import dataclass
from queue import Empty, Queue
from threading import Event, Lock, Thread
from typing import IO, TYPE_CHECKING
from lerobot.configs import TextKind
from lerobot.utils.utils import log_say
if TYPE_CHECKING:
from .context import RolloutContext
from .strategies import RolloutStrategy
logger = logging.getLogger(__name__)
_BANNER_RULE = "" * 60
def _mute_console_log_handlers() -> list[tuple[logging.Handler, int]]:
"""Mute console log handlers for the interactive session.
System logs (policy, robot, control loop) contend with the chat prompt
for the terminal, so raise every console handler above ``CRITICAL``
while the session runs. All loggers are covered, not just the root:
libraries like ``transformers`` and ``datasets`` attach their own
stderr handlers with ``propagate=False``. File handlers are left
untouched — anyone who wants a persistent log can attach one — and the
previous levels are returned so :func:`_restore_log_handlers` can undo
the muting.
"""
loggers = [logging.getLogger()]
loggers += [lg for lg in logging.Logger.manager.loggerDict.values() if isinstance(lg, logging.Logger)]
muted = []
for lg in loggers:
for handler in lg.handlers:
if isinstance(handler, logging.StreamHandler) and not isinstance(handler, logging.FileHandler):
muted.append((handler, handler.level))
handler.setLevel(logging.CRITICAL + 1)
return muted
def _restore_log_handlers(muted: list[tuple[logging.Handler, int]]) -> None:
"""Restore handler levels changed by :func:`_mute_console_log_handlers`."""
for handler, level in muted:
handler.setLevel(level)
class LinkedEvent(Event):
"""A ``threading.Event`` whose ``is_set`` also reflects a parent event.
``set``/``clear`` act only on the local flag, so the interactive session
can raise and clear its own segment-stop requests without masking (or
accidentally re-arming) the process-wide shutdown event carried by
``parent``. Every rollout strategy control loop polls
``ctx.runtime.shutdown_event.is_set()``, so installing a ``LinkedEvent``
there makes the loops react both to session commands and to real
shutdown signals.
"""
_WAIT_SLICE_S = 0.05
def __init__(self, parent: Event) -> None:
super().__init__()
self.parent = parent
def is_set(self) -> bool:
return super().is_set() or self.parent.is_set()
def wait(self, timeout: float | None = None) -> bool:
"""Wait for either the local or the parent flag.
The base ``Event.wait`` only watches the local flag, so poll in short
slices to also observe the parent. Strategy loops only call
``is_set()``; this coarse wait exists for API completeness.
"""
deadline = None if timeout is None else time.perf_counter() + timeout
while not self.is_set():
remaining = None if deadline is None else deadline - time.perf_counter()
if remaining is not None and remaining <= 0:
return False
wait_slice = self._WAIT_SLICE_S if remaining is None else min(self._WAIT_SLICE_S, remaining)
super().wait(wait_slice)
return True
@dataclass(frozen=True)
class InteractiveCommand:
"""A parsed ``/name args`` line from the interactive prompt."""
name: str
args: str = ""
def _format_task(task: str) -> str:
"""Render a task string for the operator, naming the empty case explicitly."""
return repr(task) if task else "(none — set one with /subtask <text>)"
def _strip_quotes(text: str) -> str:
"""Drop one layer of matching surrounding quotes from a command argument."""
if len(text) >= 2 and text[0] == text[-1] and text[0] in ("'", '"'):
return text[1:-1]
return text
def parse_command(line: str) -> InteractiveCommand | None:
"""Parse an input line into an :class:`InteractiveCommand`.
Commands are ``/name`` optionally followed by free-text arguments
(unused by the built-in commands, but the grammar already supports
future ones like ``/subtask grab the red cube``). Returns ``None`` for
lines that are not commands (no leading ``/`` or a bare ``/``).
"""
line = line.strip()
if not line.startswith("/"):
return None
head, *rest = line.split(maxsplit=1)
name = head[1:].lower()
if not name:
return None
return InteractiveCommand(name=name, args=rest[0].strip() if rest else "")
class StdinCommandListener:
"""Daemon thread that reads input lines and forwards them to a callback.
On POSIX the reader polls the stream with ``select`` so ``stop()`` can
end the thread promptly; elsewhere (or for file-like objects without a
file descriptor) it falls back to a blocking ``readline`` daemon thread
that dies with the process. Blank lines are skipped; end-of-file and
unexpected read errors trigger ``on_eof`` (an interactive Ctrl-D or an
exhausted piped script both mean "no more commands" — the session must
not keep the robot running with no way to command it).
Unlike :class:`lerobot.utils.keyboard_input.TerminalKeyListener`, this
reader leaves the terminal in canonical (line-buffered, echoing) mode —
the operator is typing chat-style commands, not pressing hotkeys.
"""
def __init__(
self,
on_line: Callable[[str], None],
on_eof: Callable[[], None] | None = None,
stream: IO[str] | None = None,
poll_interval_s: float = 0.2,
) -> None:
self._on_line = on_line
self._on_eof = on_eof
self._stream = stream if stream is not None else sys.stdin
self._poll_interval_s = poll_interval_s
self._running = False
self._thread: Thread | None = None
self._use_select = False
if os.name == "posix":
try:
self._stream.fileno()
self._use_select = True
except (OSError, ValueError, AttributeError):
pass
def start(self) -> None:
"""Start the reader thread (idempotent)."""
if self._thread is not None:
return
self._running = True
self._thread = Thread(target=self._run, daemon=True, name="InteractiveStdin")
self._thread.start()
if not self._use_select:
logger.info("stdin listener running in blocking mode (select unavailable for this stream)")
def stop(self) -> None:
"""Stop the reader thread.
Blocking-mode threads may be stuck inside ``readline`` and cannot be
joined; they are daemons and die with the process. Late lines are
ignored via the ``_running`` flag either way.
"""
self._running = False
thread = self._thread
self._thread = None
if thread is not None and thread.is_alive() and self._use_select:
thread.join(timeout=1.0)
def _run(self) -> None:
if self._use_select:
self._run_select()
else:
self._run_blocking()
def _run_select(self) -> None:
"""Poll the file descriptor and split lines from raw bytes.
Reading raw bytes (instead of ``stream.readline()``) matters: a
buffered file object can slurp several lines off the descriptor at
once, after which ``select`` reports the drained fd as not-ready and
the buffered lines would never be delivered — breaking pasted or
piped command sequences.
"""
fd = self._stream.fileno()
buffer = b""
while self._running:
try:
ready, _, _ = select.select([fd], [], [], self._poll_interval_s)
except (OSError, ValueError): # stream closed underneath us
self._emit_read_error()
return
if not ready:
continue
try:
chunk = os.read(fd, 4096)
except OSError:
self._emit_read_error()
return
if not self._running:
return
if chunk == b"": # EOF: Ctrl-D or the piped input ended
self._emit_line(buffer) # a final command without trailing newline still counts
self._emit_eof()
return
buffer += chunk
while b"\n" in buffer:
raw, buffer = buffer.split(b"\n", 1)
self._emit_line(raw)
def _run_blocking(self) -> None:
while self._running:
try:
line = self._stream.readline()
except (OSError, ValueError):
self._emit_read_error()
return
if not self._running:
return
if line == "": # EOF
self._emit_eof()
return
self._emit_line(line.encode() if isinstance(line, str) else line)
def _emit_line(self, raw: bytes) -> None:
line = raw.decode(errors="replace").strip()
if not line:
return
try:
self._on_line(line)
except Exception: # never let a handler error kill the reader thread
logger.exception("Error while handling interactive input %r", line)
def _emit_eof(self) -> None:
logger.info("Interactive input stream closed (EOF)")
if self._on_eof is not None:
try:
self._on_eof()
except Exception:
logger.exception("Error while handling interactive input EOF")
def _emit_read_error(self) -> None:
"""Treat an unexpected read failure like EOF so the session shuts down.
A dead command channel must not leave the robot running with no way
to stop it. Deliberate ``stop()`` calls clear ``_running`` first and
do not reach this path.
"""
if self._running:
logger.warning("Interactive input stream failed — treating as EOF")
self._emit_eof()
@dataclass(frozen=True)
class TextQueryRequest:
"""One immutable policy question paired with the view seen at submission."""
question: str
observation: dict
class TextQueryWorker:
"""Single background worker for non-blocking interactive policy questions.
At most one request may be queued or running. This bounds the number of
retained image tensors (the observation can live on the GPU) and gives the
operator an explicit "busy" response instead of accumulating questions
against increasingly stale views.
"""
_STOP = object()
_JOIN_TIMEOUT_S = 5.0
def __init__(
self,
answer: Callable[[TextQueryRequest], str],
on_answer: Callable[[TextQueryRequest, str], None],
on_error: Callable[[TextQueryRequest, Exception], None],
) -> None:
self._answer = answer
self._on_answer = on_answer
self._on_error = on_error
self._queue: Queue[TextQueryRequest | object] = Queue(maxsize=1)
self._state_lock = Lock()
self._busy = False
self._stopping = Event()
self._stop_enqueued = False
self._thread: Thread | None = None
@property
def busy(self) -> bool:
with self._state_lock:
return self._busy
def start(self) -> None:
"""Start the worker (idempotent)."""
with self._state_lock:
if self._thread is not None or self._stopping.is_set():
return
self._thread = Thread(target=self._run, daemon=True, name="InteractiveTextQuery")
self._thread.start()
def submit(self, request: TextQueryRequest) -> bool:
"""Queue ``request`` without blocking; return ``False`` when busy or stopping."""
with self._state_lock:
if self._busy or self._stopping.is_set():
return False
self._busy = True
# stop() takes the same lock before publishing its sentinel, so a
# successful admission cannot race with shutdown and hit Queue.Full.
self._queue.put_nowait(request)
return True
def cancel(self) -> None:
"""Reject new work, discard a queued request, and suppress late callbacks."""
with self._state_lock:
self._stopping.set()
discard_request = not self._stop_enqueued
if discard_request:
self._discard_queued_request()
def stop(self, timeout_s: float = _JOIN_TIMEOUT_S) -> bool:
"""Cancel queued work and give an active model call bounded time to finish.
Returns ``False`` when decoding is still stuck after ``timeout_s``. The
worker is a daemon and callbacks stay suppressed, allowing hardware
teardown to proceed instead of hanging indefinitely.
"""
self.cancel()
with self._state_lock:
thread = self._thread
enqueue_stop = thread is not None and not self._stop_enqueued
self._stop_enqueued = self._stop_enqueued or enqueue_stop
if thread is None:
return True
if enqueue_stop:
self._queue.put(self._STOP)
thread.join(timeout=timeout_s)
stopped = not thread.is_alive()
if stopped:
with self._state_lock:
if self._thread is thread:
self._thread = None
return stopped
def _discard_queued_request(self) -> None:
try:
item = self._queue.get_nowait()
except Empty:
return
self._queue.task_done()
if item is self._STOP:
# A concurrent/repeated cancel must not consume the sentinel that
# an earlier stop() already published for the worker.
self._queue.put_nowait(self._STOP)
return
with self._state_lock:
self._busy = False
def _run(self) -> None:
while True:
item = self._queue.get()
try:
if item is self._STOP:
return
request = item
assert isinstance(request, TextQueryRequest)
if self._stopping.is_set():
continue
try:
answer = self._answer(request)
except Exception as exc: # a language failure must not end robot control
self._deliver(self._on_error, request, exc)
else:
self._deliver(self._on_answer, request, answer)
finally:
if item is not self._STOP:
with self._state_lock:
self._busy = False
self._queue.task_done()
def _deliver(self, callback: Callable[..., None], *args) -> None:
"""Linearize a result callback with cancellation."""
with self._state_lock:
if not self._stopping.is_set():
callback(*args)
class InteractiveSession:
"""Drive a rollout strategy from chat-style stdin commands.
The session owns the outer lifecycle: after ``strategy.setup(ctx)`` the
robot stays idle until ``/start``. Each run *segment* executes
``strategy.run(ctx)`` on the calling (main) thread until the operator
interrupts it or the strategy returns on its own (e.g. ``--duration``
elapsed). ``/reset`` pauses the inference engine and returns the robot
to its initial position while hardware and policy stay warm; ``/stop``
ends the session so the caller can run ``strategy.teardown(ctx)`` — the
same shutdown routine as non-interactive rollouts.
Requires ``ctx.runtime.shutdown_event`` to be a :class:`LinkedEvent`
(installed by ``lerobot-rollout`` when ``--interactive=true``): the
session sets the local flag to end a segment, and process signals still
propagate through the parent.
Commands are last-write-wins: ``/reset`` and ``/stop`` cancel a pending
``/start`` so the robot never starts moving after the operator's final
command asked it not to. End-of-file on the command stream stops the
session (a closed stdin means there is no way left to command the
robot), so piped scripts must keep stdin open for the intended session
duration, e.g. ``(printf '/start\\n'; sleep 60; printf '/stop\\n') |
lerobot-rollout ... --interactive=true``.
"""
_POLL_INTERVAL_S = 0.2
def __init__(
self,
strategy: RolloutStrategy,
ctx: RolloutContext,
input_stream: IO[str] | None = None,
) -> None:
stop_event = ctx.runtime.shutdown_event
if not isinstance(stop_event, LinkedEvent):
raise TypeError(
"InteractiveSession requires ctx.runtime.shutdown_event to be a LinkedEvent so "
"/reset can end a run segment without triggering process shutdown. Build the "
"rollout context with build_rollout_context(cfg, LinkedEvent(shutdown_event))."
)
self._strategy = strategy
self._ctx = ctx
self._segment_stop = stop_event
self._global_shutdown = stop_event.parent
# The instruction the rollout was launched with; /reset restores it.
self._initial_task = ctx.policy.inference.task
self._listener = StdinCommandListener(self._handle_line, on_eof=self._handle_eof, stream=input_stream)
self._text_query = TextQueryWorker(
self._answer_text_query,
self._report_text_answer,
self._report_text_error,
)
# Written by the listener thread, consumed by the main loop.
self._start_requested = Event()
self._reset_requested = Event()
self._stop_requested = Event()
self._wake = Event()
self._running = Event()
# name -> (handler, argument hint, help line); /help and the banner
# render from this table, so future commands stay documented for free.
self._commands: dict[str, tuple[Callable[[InteractiveCommand], None], str, str]] = {
"start": (self._cmd_start, "", "start (or restart) the policy control loop"),
"subtask": (self._cmd_subtask, " <text>", "set the instruction the policy follows"),
"ask": (self._cmd_ask, " <question>", "ask the policy about the latest view"),
"reset": (self._cmd_reset, "", "stop movement, return to initial position, restore the task"),
"stop": (self._cmd_stop, "", "end the session and shut down"),
"help": (self._cmd_help, "", "show this help"),
}
# ------------------------------------------------------------------
# Main-thread session loop
# ------------------------------------------------------------------
def run(self) -> None:
"""Run the session until ``/stop``, EOF, engine failure, or a shutdown signal."""
play_sounds = self._ctx.runtime.cfg.play_sounds
muted_handlers: list[tuple[logging.Handler, int]] = []
saved_warning_filters = warnings.filters[:]
try:
muted_handlers = _mute_console_log_handlers()
warnings.simplefilter("ignore")
self._print(self._render_banner())
self._text_query.start()
self._listener.start()
while not self._global_shutdown.is_set():
if self._ctx.policy.inference.failed:
self._report_engine_failure()
break
if self._stop_requested.is_set():
break
if self._reset_requested.is_set():
self._reset_requested.clear()
self._reset_robot()
continue
if self._start_requested.is_set():
self._start_requested.clear()
self._run_segment()
continue
self._wake.wait(timeout=self._POLL_INTERVAL_S)
self._wake.clear()
finally:
self._listener.stop()
# A model call cannot be force-cancelled safely. Give it a bounded
# grace period, then prioritize hardware teardown if it is wedged.
if not self._text_query.stop():
self._print(
"Policy question did not finish within 5 seconds — "
"continuing hardware shutdown; its daemon thread will be abandoned."
)
# Restore before log_say so teardown logs are visible again.
_restore_log_handlers(muted_handlers)
warnings.filters[:] = saved_warning_filters
log_say("Interactive session ended", play_sounds)
def _report_engine_failure(self) -> None:
"""Surface a fatal engine error despite the muted console logging."""
self._print("Inference engine failed — shutting down.")
failure_traceback = self._ctx.policy.inference.failure_traceback
if failure_traceback:
self._print(failure_traceback)
else:
self._print("Re-run without --interactive=true to see the error output.")
def _run_segment(self) -> None:
"""Execute one ``strategy.run`` segment until interrupted or finished."""
engine = self._ctx.policy.inference
# Clear the local flag *before* checking the request flags: command
# handlers set their flag first and the segment-stop event second, so
# a /reset or /stop racing with this /start is either seen here or
# ends the freshly started loop on its first tick.
self._segment_stop.clear()
if self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set():
return
self._strategy.reset_control_state()
log_say("Starting rollout", self._ctx.runtime.cfg.play_sounds)
self._print(
f"Rollout running — task {_format_task(engine.task)}. "
"/subtask <text> to change it, /ask <question> to query the policy, "
"/reset to return to initial position, /stop to shut down."
)
self._running.set()
try:
self._strategy.run(self._ctx)
finally:
self._running.clear()
engine.pause()
if engine.failed:
return # the session loop reports the failure and shuts down
if not (
self._stop_requested.is_set() or self._reset_requested.is_set() or self._global_shutdown.is_set()
):
self._print(
"Rollout run ended on its own (duration reached). Robot is holding position — "
"/start to run again, /reset to return to initial position, /stop to shut down."
)
def _reset_robot(self) -> None:
"""Pause inference and return the robot home (the task was restored by ``/reset``)."""
self._print("Resetting — returning the robot to its initial position...")
self._ctx.policy.inference.pause()
log_say("Resetting robot to initial position", self._ctx.runtime.cfg.play_sounds)
if self._ctx.hardware.initial_position:
self._strategy.return_to_initial_position(self._ctx.hardware)
self._print("Robot reset — holding at initial position. /start to run.")
else:
logger.warning("No initial position captured — skipping the return move")
self._print("Robot paused — no initial position captured, holding current pose. /start to run.")
# ------------------------------------------------------------------
# Command handlers (called from the listener thread; only set flags)
# ------------------------------------------------------------------
def _handle_line(self, line: str) -> None:
cmd = parse_command(line)
if cmd is None:
self._print("Input not recognized — commands start with '/'. Type /help for the list.")
return
entry = self._commands.get(cmd.name)
if entry is None:
self._print(f"Unknown command '/{cmd.name}'. Type /help for the list.")
return
handler = entry[0]
handler(cmd)
def _handle_eof(self) -> None:
self._print("Input stream closed — stopping the session.")
self._request_stop()
def _cmd_start(self, cmd: InteractiveCommand) -> None:
if self._running.is_set():
self._print("Already running — /reset to pause first, or /stop to shut down.")
return
if self._text_query.busy:
self._print("A policy question is still finishing — wait for it before /start.")
return
self._start_requested.set()
self._wake.set()
def _cmd_subtask(self, cmd: InteractiveCommand) -> None:
engine = self._ctx.policy.inference
if not cmd.args:
self._print(f"Current task: {_format_task(engine.task)}")
return
task = _strip_quotes(cmd.args)
previous = engine.task
# Publishing the string is all this thread does: the engine applies the
# switch on its own inference thread.
if engine.set_task(task):
self._print(
f"Task: {_format_task(previous)}{_format_task(task)} "
"(applies from the next policy inference)"
)
else:
self._print(f"Task unchanged: {_format_task(task)}")
def _cmd_ask(self, cmd: InteractiveCommand) -> None:
question = _strip_quotes(cmd.args)
if not question:
self._print("Usage: /ask <question>")
return
engine = self._ctx.policy.inference
if not engine.supports_text_generation():
self._print("This policy does not support /ask (it has no text-generation head).")
return
if not self._running.is_set():
self._print("The rollout is not running — /start it before using /ask.")
return
observation = engine.snapshot_text_observation()
if observation is None:
self._print("No policy observation is available yet — /start the rollout and try again.")
return
request = TextQueryRequest(question=question, observation=observation)
if not self._text_query.submit(request):
self._print("A policy question is already being answered — try again when it finishes.")
return
self._print(f"Question queued: {question!r} (the rollout keeps running)")
def _answer_text_query(self, request: TextQueryRequest) -> str:
return self._ctx.policy.inference.generate_text(
request.observation,
kind=TextKind.VQA,
user_text=request.question,
)
def _report_text_answer(self, request: TextQueryRequest, answer: str) -> None:
if answer:
self._print(f"[policy] {answer}")
else:
self._print(f"The policy returned no answer for {request.question!r}.")
def _report_text_error(self, request: TextQueryRequest, exc: Exception) -> None:
self._print(f"Policy question failed ({type(exc).__name__}): {exc}")
def _cmd_reset(self, cmd: InteractiveCommand) -> None:
# Last command wins: a /start still waiting to be serviced is cancelled
# so the robot never starts moving after the operator asked it not to.
# Flag first, segment-stop second (see the ordering note in _run_segment).
self._start_requested.clear()
# Restore the task here rather than in _reset_robot (which runs later, on
# the main thread) so that both task writers run on this thread and are
# ordered by command order — otherwise a /subtask typed right after
# /reset would be silently reverted by the deferred restore.
engine = self._ctx.policy.inference
if engine.set_task(self._initial_task):
self._print(f"Task restored to {_format_task(self._initial_task)}")
# Homing changes the scene outside normal inference. Invalidate the
# cached VLM input synchronously so a following /ask cannot capture
# the pre-reset view while the main thread is still unwinding.
engine.invalidate_text_observation()
self._reset_requested.set()
self._segment_stop.set()
self._wake.set()
def _cmd_stop(self, cmd: InteractiveCommand) -> None:
self._request_stop()
def _request_stop(self) -> None:
# Suppress a queued/finishing answer as soon as /stop or EOF is
# observed; stop() in the session's finally block gives the model call
# bounded time to finish before hardware teardown continues.
self._text_query.cancel()
self._start_requested.clear() # last command wins, see _cmd_reset
self._stop_requested.set()
self._segment_stop.set()
self._wake.set()
def _cmd_help(self, cmd: InteractiveCommand) -> None:
self._print(self._render_help())
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _render_help(self) -> str:
usages = {name: f"/{name}{entry[1]}" for name, entry in self._commands.items()}
width = max(len(usage) for usage in usages.values())
lines = [f" {usages[name]:<{width}} {entry[2]}" for name, entry in self._commands.items()]
return "Available commands:\n" + "\n".join(lines)
def _render_banner(self) -> str:
return (
f"{_BANNER_RULE}\n"
"Interactive rollout session — the robot will NOT move until you type /start.\n"
f"Task: {_format_task(self._initial_task)}\n"
f"{self._render_help()}\n"
"System logs and warnings are muted during the session; they resume when it ends.\n"
f"{_BANNER_RULE}"
)
@staticmethod
def _print(message: str) -> None:
"""User-facing chat output; logging stays on stderr, replies on stdout."""
print(message, flush=True)
+17 -4
View File
@@ -63,12 +63,25 @@ class RolloutStrategy(abc.ABC):
self._interpolator = ActionInterpolator(multiplier=ctx.runtime.cfg.interpolation_multiplier)
self._engine = ctx.policy.inference
logger.info("Starting inference engine...")
self._engine.reset()
self.reset_control_state()
self._engine.start()
self._warmup_flushed = False
self._cached_obs_processed = None
logger.info("Inference engine started")
def reset_control_state(self) -> None:
"""Clear episode-scoped control state so a paused session can restart cleanly.
Resets the inference engine (policy hidden state, action queues), the
action interpolator, and the cached processed observation. Used by the
interactive session between run segments; only call while the control
loop is not running.
"""
if self._engine is not None:
self._engine.reset()
if self._interpolator is not None:
self._interpolator.reset()
self._cached_obs_processed = None
def _process_observation_and_notify(self, processors: ProcessorContext, obs_raw: dict) -> dict:
"""Run the observation processor and notify the engine — throttled to policy ticks.
@@ -125,7 +138,7 @@ class RolloutStrategy(abc.ABC):
if robot.is_connected:
if return_to_initial_position and hw.initial_position:
logger.info("Returning robot to initial position before shutdown...")
self._return_to_initial_position(hw)
self.return_to_initial_position(hw)
elif not return_to_initial_position:
logger.info(
"Skipping return-to-initial-position (disabled by config); leaving robot in final pose."
@@ -138,7 +151,7 @@ class RolloutStrategy(abc.ABC):
teleop.disconnect()
@staticmethod
def _return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
def return_to_initial_position(hw: HardwareContext, duration_s: float = 3.0, fps: int = 50) -> None:
"""Smoothly interpolate the robot back to its initial position."""
robot = hw.robot_wrapper
target = hw.initial_position
+2 -2
View File
@@ -165,7 +165,7 @@ class EpisodicStrategy(RolloutStrategy):
elif self.config.reset_to_initial_position:
# No teleop: return the robot to its startup position.
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
self._reset_loop(
ctx=ctx,
@@ -187,7 +187,7 @@ class EpisodicStrategy(RolloutStrategy):
# returns to its initial joint positions captured at startup
if not teleop and self.config.reset_to_initial_position:
self._return_to_initial_position(hw=ctx.hardware, duration_s=1)
self.return_to_initial_position(hw=ctx.hardware, duration_s=1)
continue
+29 -3
View File
@@ -44,6 +44,18 @@ Usage examples
--robot.port=/dev/ttyACM0 \\
--task="pick up cube" --duration=30
# Base mode — interactive session: the robot stays idle until /start is
# typed; /subtask <text> re-instructs the policy mid-run; /ask <question>
# queries a supported policy text head in the background; /reset returns
# to the initial position (hardware and policy stay warm); /stop shuts down
lerobot-rollout \\
--strategy.type=base \\
--policy.path=lerobot/act_koch_real \\
--robot.type=koch_follower \\
--robot.port=/dev/ttyACM0 \\
--task="pick up cube" \\
--interactive=true
# Base mode — RTC inference for slow VLAs (Pi0, Pi0.5, SmolVLA)
lerobot-rollout \\
--strategy.type=base \\
@@ -173,7 +185,13 @@ from lerobot.robots import ( # noqa: F401
so_follower,
unitree_g1 as unitree_g1_robot,
)
from lerobot.rollout import RolloutConfig, build_rollout_context, create_strategy
from lerobot.rollout import (
InteractiveSession,
LinkedEvent,
RolloutConfig,
build_rollout_context,
create_strategy,
)
from lerobot.teleoperators import ( # noqa: F401
Teleoperator,
TeleoperatorConfig,
@@ -215,6 +233,10 @@ def rollout(cfg: RolloutConfig):
signal_handler = ProcessSignalHandler(use_threads=True, display_pid=False)
shutdown_event = signal_handler.shutdown_event
if cfg.interactive:
# Session commands (/reset, /stop) end the running control loop by setting
# the local flag; process signals still propagate through the parent event.
shutdown_event = LinkedEvent(shutdown_event)
logger.info("Building rollout context...")
ctx = build_rollout_context(cfg, shutdown_event)
@@ -230,8 +252,12 @@ def rollout(cfg: RolloutConfig):
try:
strategy.setup(ctx)
logger.info("Rollout setup complete, starting rollout...")
strategy.run(ctx)
if cfg.interactive:
logger.info("Rollout setup complete — starting interactive session (robot idle until /start)")
InteractiveSession(strategy, ctx).run()
else:
logger.info("Rollout setup complete, starting rollout...")
strategy.run(ctx)
except KeyboardInterrupt:
logger.info("Interrupted by user")
finally:
-205
View File
@@ -1,205 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Doctest plumbing so the examples in our docstrings actually run.
Adapted from `transformers.testing_utils`. Two stdlib limitations make this necessary:
1. Ruff is configured with `docstring-code-format = true`, which reformats code inside docstrings and
removes the blank line before the closing fence. stdlib's `_EXAMPLE_RE` then swallows the ` ``` ` into
the expected-output group, so every example that has output fails. [`LeRobotDocTestParser`] patches the
regex to stop at a fence.
2. `doctest.DocTestFinder` reports the wrong line number for `@property` and `functools.wraps` objects
(https://bugs.python.org/issue17446). Our hardware API is property-heavy — `observation_features`,
`action_features`, `is_connected`, `is_calibrated` are all abstract properties — so
[`LeRobotDoctestModule`] unwraps them before locating the example.
Two environment variables skip whole example blocks by content:
- `SKIP_CUDA_DOCTEST=1` skips examples that need a GPU.
- `SKIP_HARDWARE_DOCTEST=1` skips examples that need a physical robot or a Hub download.
Both are heuristics over the example source. They are deliberately blunt: an example that is skipped
needlessly costs nothing, whereas one that runs on a machine without the hardware hangs or fails.
"""
import doctest
import functools
import inspect
import os
import re
import sys
from collections.abc import Iterable
from _pytest.doctest import (
DoctestItem,
DoctestModule,
_get_checker,
_get_continue_on_failure,
_get_runner,
get_optionflags,
)
from _pytest.nodes import Collector
from _pytest.outcomes import skip
# Calls whose progress bars would otherwise be compared against the expected output. The lookahead leaves
# lines that already carry a directive alone.
_NOISY_CALL_PATTERN = re.compile(r"(>>> (?!.*# doctest:).*(?:load_dataset|LeRobotDataset)\(.*)")
_CUDA_PATTERN = re.compile(r"cuda|to\(0\)|device=0")
# Serial ports, video devices, and the connect/scan calls that talk to real hardware.
_HARDWARE_PATTERN = re.compile(r"/dev/tty|/dev/video|COM\d|\.connect\(|find_cameras\(|find_port\(")
# Anything that reaches the Hub over the network.
_HUB_PATTERN = re.compile(r"from_pretrained\(|push_to_hub\(|snapshot_download\(|load_dataset\(")
def preprocess_string(string: str, skip_cuda_tests: bool, skip_hardware_tests: bool) -> str:
"""Prepare a docstring or `.mdx` file to be run by doctest.
Args:
string (`str`):
A whole file's contents for `.mdx`, or a single docstring for a Python file. Either may hold
several fenced examples.
skip_cuda_tests (`bool`):
Whether to drop examples that look like they need a GPU.
skip_hardware_tests (`bool`):
Whether to drop examples that look like they need a robot or a Hub download.
Returns:
`str`: The input with `# doctest: +IGNORE_RESULT` injected on noisy calls, or an empty string if
the examples were skipped — in which case no doctest is collected for it at all.
"""
# Match against the example lines only, not the surrounding prose, so that a docstring merely
# *describing* CUDA or a serial port is not mistaken for one that uses them.
example_lines = "\n".join(
line for line in string.splitlines() if line.lstrip().startswith((">>>", "..."))
)
if not example_lines:
return string
if skip_cuda_tests and _CUDA_PATTERN.search(example_lines):
return ""
if skip_hardware_tests and (
_HARDWARE_PATTERN.search(example_lines) or _HUB_PATTERN.search(example_lines)
):
return ""
return _NOISY_CALL_PATTERN.sub(r"\1 # doctest: +IGNORE_RESULT", string)
class LeRobotDocTestParser(doctest.DocTestParser):
"""A `DocTestParser` that understands fenced, auto-formatted code blocks.
Ruff's `docstring-code-format` removes the blank line before a closing fence, after which stdlib's
`_EXAMPLE_RE` reads the fence itself as part of the expected output and every example with output
fails. The regex below is the stdlib one plus a clause that stops matching at a fence.
"""
# fmt: off
_EXAMPLE_RE = re.compile(r'''
# Source consists of a PS1 line followed by zero or more PS2 lines.
(?P<source>
(?:^(?P<indent> [ ]*) >>> .*) # PS1 line
(?:\n [ ]* \.\.\. .*)*) # PS2 lines
\n?
# Want consists of any non-blank lines that do not start with PS1.
(?P<want> (?:(?![ ]*$) # Not a blank line
(?![ ]*>>>) # Not a line starting with PS1
(?:(?!```).)* # Stop at a closing fence: formatting drops the blank line before it
(?:\n|$) # Match a new line or end of string
)*)
''', re.MULTILINE | re.VERBOSE
)
# fmt: on
skip_cuda_tests: bool = os.environ.get("SKIP_CUDA_DOCTEST", "0") == "1"
skip_hardware_tests: bool = os.environ.get("SKIP_HARDWARE_DOCTEST", "0") == "1"
def parse(self, string, name="<string>"):
"""Preprocess `string`, then parse it as stdlib would.
Args:
string (`str`):
The docstring or file contents to parse.
name (`str`, *optional*, defaults to `"<string>"`):
Name used in failure messages.
Returns:
`list`: The examples and interleaved text, as returned by `doctest.DocTestParser.parse`.
"""
string = preprocess_string(string, self.skip_cuda_tests, self.skip_hardware_tests)
return super().parse(string, name)
class LeRobotDoctestModule(DoctestModule):
"""A pytest `DoctestModule` that collects with [`LeRobotDocTestParser`].
`doctest.DocTestFinder` binds its default parser at class-definition time, so patching
`doctest.DocTestParser` in `conftest.py` does not reach the finder pytest builds. The parser has to be
passed in explicitly, which means reimplementing `collect`. It mirrors pytest's own implementation.
"""
def collect(self) -> Iterable[DoctestItem]:
"""Collect the doctests in this module.
Returns:
`Iterable[DoctestItem]`: One item per example-bearing docstring. Docstrings whose examples were
dropped by `preprocess_string` yield nothing.
"""
class MockAwareDocTestFinder(doctest.DocTestFinder):
"""A doctest finder that reports correct line numbers for properties and wrapped callables."""
# Fixed upstream in CPython 3.11.9 / 3.12.3; kept for older interpreters. Our hardware API is
# property-heavy (`observation_features`, `is_connected`, ...), so a wrong line number here
# would point every failure at the decorator. https://github.com/python/cpython/issues/61648
def _find_lineno(self, obj, source_lines):
if isinstance(obj, property):
obj = getattr(obj, "fget", obj)
if hasattr(obj, "__wrapped__"):
obj = inspect.unwrap(obj)
return super()._find_lineno(obj, source_lines)
if sys.version_info < (3, 13):
# `cached_property` is otherwise never considered part of the current module and its
# examples are silently skipped. https://github.com/python/cpython/issues/107995
def _from_module(self, module, object):
if isinstance(object, functools.cached_property):
object = object.func
return super()._from_module(module, object)
try:
module = self.obj
except Collector.CollectError:
if self.config.getvalue("doctest_ignore_import_errors"):
skip(f"unable to import module {self.path!r}")
else:
raise
# Doctests support fixtures via `getfixture` and autouse.
self.session._fixturemanager.parsefactories(self)
finder = MockAwareDocTestFinder(parser=LeRobotDocTestParser())
optionflags = get_optionflags(self.config)
runner = _get_runner(
verbose=False,
optionflags=optionflags,
checker=_get_checker(),
continue_on_failure=_get_continue_on_failure(self.config),
)
for test in finder.find(module, module.__name__):
if test.examples: # Skip docstrings with no examples, and blocks dropped by the parser.
yield DoctestItem.from_parent(self, name=test.name, runner=runner, dtest=test)
@@ -16,6 +16,7 @@ from conftest import (
make_config,
set_seed_all,
) # noqa: E402
from lerobot.policies.vla_jepa.action_head import ( # noqa: E402
VLAJEPAActionHead,
)
@@ -3,8 +3,8 @@
from __future__ import annotations
import pytest
from conftest import ACTION_DIM, ACTION_HORIZON, IMAGE_SIZE, NUM_VIDEO_FRAMES, STATE_DIM, make_config
from lerobot.configs.types import FeatureType, PolicyFeature
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig
from lerobot.utils.constants import ACTION, OBS_IMAGES, OBS_STATE
+1
View File
@@ -32,6 +32,7 @@ from conftest import ( # noqa: E402
make_train_batch,
set_seed_all,
)
from lerobot.policies.vla_jepa.configuration_vla_jepa import VLAJEPAConfig # noqa: E402
from lerobot.policies.vla_jepa.modeling_vla_jepa import VLAJEPAPolicy # noqa: E402
from lerobot.utils.constants import ACTION # noqa: E402
+81 -1
View File
@@ -16,6 +16,8 @@
"""Test script to verify Wall-X policy integration with LeRobot"""
from types import SimpleNamespace
import pytest
import torch
@@ -24,11 +26,15 @@ pytest.importorskip("peft")
pytest.importorskip("transformers")
pytest.importorskip("torchdiffeq")
from lerobot.configs import TextKind # noqa: E402
from lerobot.policies.factory import make_policy_config # noqa: E402
from lerobot.policies.wall_x import (
WallXConfig, # noqa: E402
)
from lerobot.policies.wall_x.modeling_wall_x import WallXPolicy # noqa: E402
from lerobot.policies.wall_x.modeling_wall_x import ( # noqa: E402
Qwen2_5_VLMoEForAction,
WallXPolicy,
)
from lerobot.policies.wall_x.processor_wall_x import make_wall_x_pre_post_processors # noqa: E402
from lerobot.policies.wall_x.qwen_model import Qwen2_5_VLMoEModel, Qwen2_5_VLTextConfig # noqa: E402
from lerobot.utils.random_utils import set_seed # noqa: E402
@@ -76,6 +82,80 @@ def test_moe_model_captures_requested_hidden_states_and_attentions():
assert len(output.attentions) == config.num_hidden_layers
def _make_unloaded_policy():
policy = WallXPolicy.__new__(WallXPolicy)
torch.nn.Module.__init__(policy)
policy.config = SimpleNamespace(
text_temperature=0.0,
text_top_p=1.0,
)
return policy
def test_policy_exposes_grounded_text_generation(monkeypatch):
class Inputs(dict):
__getattr__ = dict.__getitem__
class Tokenizer:
eos_token_id = 2
pad_token_id = 0
@staticmethod
def batch_decode(token_ids, **kwargs):
del kwargs
assert torch.equal(token_ids, torch.tensor([[7, 8]]))
return ["The mug is beside the bowl."]
class Model:
processor = SimpleNamespace(tokenizer=Tokenizer())
@staticmethod
def generate(input_ids, **kwargs):
del kwargs
return torch.cat([input_ids, torch.tensor([[7, 8]])], dim=1)
policy = _make_unloaded_policy()
policy.model = Model()
inputs = Inputs(input_ids=torch.tensor([[1, 2, 3]]), attention_mask=torch.ones(1, 3))
monkeypatch.setattr(policy, "_build_text_inputs", lambda *args, **kwargs: inputs)
batch = {"observation.state": torch.zeros(1, 7), "task": "pick up the cup"}
assert (
policy.generate_text(batch, kind=TextKind.VQA, user_text="Where is the mug?")
== "The mug is beside the bowl."
)
assert policy.supports_text_generation()
prompt = policy._format_text_prompt(
"Where is the mug?",
TextKind.VQA,
["observation.images.face_view"],
)
assert "Observation: front view:" in prompt
assert "Instruction: Where is the mug?" in prompt
assert prompt.endswith("<|im_start|>assistant\n")
def test_text_generation_synthesizes_missing_cache_position():
class Cache:
@staticmethod
def get_seq_length():
return 3
pixel_values = torch.ones(1, 3, 4, 4)
inputs = Qwen2_5_VLMoEForAction.prepare_inputs_for_generation(
object(),
torch.tensor([[9]]),
past_key_values=Cache(),
pixel_values=pixel_values,
)
assert torch.equal(inputs["cache_position"], torch.tensor([3]))
assert torch.equal(inputs["input_ids"], torch.tensor([[9]]))
# A continuation token reuses the KV cache, so the image is not encoded again.
assert inputs["pixel_values"] is None
@require_cuda
@require_hf_token
def test_policy_instantiation():
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -36,7 +36,7 @@ N_EPISODES = 2
EPISODE_LENGTH = 12
def test_ema_config_defaults_match_the_reference():
def test_ema_config_defaults_match_reference():
cfg = EMAConfig()
assert not cfg.enable
assert cfg.inv_gamma == 1.0
+79 -34
View File
@@ -21,10 +21,8 @@ This module tests multi-GPU training functionality with accelerate.
These tests are designed to run on machines with 2+ GPUs and are executed
in the nightly CI workflow.
The tests launch `lerobot-train` through `accelerate launch` in a subprocess to properly test the
distributed training environment. Accelerate is used as a plain launcher only: the topology comes
from `--parallelism.*` flags, never from an accelerate YAML config (see
`lerobot.distributed.factory.guard_against_env_interference`).
The tests automatically generate accelerate configs and launch training
with subprocess to properly test the distributed training environment.
"""
import os
@@ -60,25 +58,73 @@ def download_dataset(repo_id, episodes):
print(f"Dataset {repo_id} downloaded successfully")
def run_accelerate_training(config_args, num_processes=4):
def _write_multi_gpu_config(f, num_processes):
f.write("compute_environment: LOCAL_MACHINE\n")
f.write("distributed_type: MULTI_GPU\n")
f.write("mixed_precision: 'no'\n")
f.write(f"num_processes: {num_processes}\n")
f.write("use_cpu: false\n")
f.write("gpu_ids: all\n")
f.write("downcast_bf16: 'no'\n")
f.write("machine_rank: 0\n")
f.write("main_training_function: main\n")
f.write("num_machines: 1\n")
f.write("rdzv_backend: static\n")
f.write("same_network: true\n")
def _write_fsdp_config(f, num_processes):
# FSDP1 with FULL_SHARD (ZeRO-3-equivalent) and FULL_STATE_DICT, matching
# docs/source/multi_gpu_training.mdx. ACT's repeated transformer blocks are the wrap units;
# fsdp_use_orig_params is required because LeRobot builds the optimizer before prepare().
f.write("compute_environment: LOCAL_MACHINE\n")
f.write("distributed_type: FSDP\n")
f.write("mixed_precision: 'no'\n")
f.write(f"num_processes: {num_processes}\n")
f.write("use_cpu: false\n")
f.write("gpu_ids: all\n")
f.write("machine_rank: 0\n")
f.write("main_training_function: main\n")
f.write("num_machines: 1\n")
f.write("rdzv_backend: static\n")
f.write("same_network: true\n")
f.write("fsdp_config:\n")
f.write(" fsdp_version: 1\n")
f.write(" fsdp_sharding_strategy: FULL_SHARD\n")
f.write(" fsdp_auto_wrap_policy: TRANSFORMER_BASED_WRAP\n")
f.write(" fsdp_transformer_layer_cls_to_wrap: ACTEncoderLayer,ACTDecoderLayer\n")
f.write(" fsdp_use_orig_params: true\n")
f.write(" fsdp_state_dict_type: FULL_STATE_DICT\n")
def run_accelerate_training(config_args, num_processes=4, temp_dir=None, distributed_type="MULTI_GPU"):
"""
Helper function to run training with accelerate launch.
`accelerate launch` is used as a plain launcher (no `--config_file`): it only sets the
rendezvous env vars, and the layout — DDP by default, FSDP with `--parallelism.dp_shard` —
comes from `config_args`.
Args:
config_args: List of config arguments to pass to lerobot_train.py
num_processes: Number of processes (GPUs) to use
temp_dir: Temporary directory for outputs
distributed_type: "MULTI_GPU" (DDP) or "FSDP" — selects the generated accelerate config.
Returns:
subprocess.CompletedProcess result
"""
config_path = Path(temp_dir) / "accelerate_config.yaml"
# Write YAML config
with open(config_path, "w") as f:
if distributed_type == "FSDP":
_write_fsdp_config(f, num_processes)
else:
_write_multi_gpu_config(f, num_processes)
cmd = [
"accelerate",
"launch",
f"--num_processes={num_processes}",
"--config_file",
str(config_path),
"-m",
"lerobot.scripts.lerobot_train",
] + config_args
@@ -127,7 +173,7 @@ class TestMultiGPUTraining:
"--num_workers=0",
]
result = run_accelerate_training(config_args, num_processes=4)
result = run_accelerate_training(config_args, num_processes=4, temp_dir=temp_dir)
# Check that training completed successfully
assert result.returncode == 0, (
@@ -170,7 +216,7 @@ class TestMultiGPUTraining:
"--num_workers=0",
]
result = run_accelerate_training(config_args, num_processes=2)
result = run_accelerate_training(config_args, num_processes=2, temp_dir=temp_dir)
assert result.returncode == 0, (
f"Training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
@@ -200,12 +246,11 @@ class TestMultiGPUTraining:
def test_fsdp_optimizer_save_and_resume(self):
"""
Test that FSDP saves the sharded optimizer state and can resume from it.
Test that FSDP saves the (gathered) optimizer state and can resume from it.
Trains a few steps under FSDP2 (`--parallelism.dp_shard=2`), verifies the DCP optimizer
shards are written next to the rest of the training state, then resumes from the
checkpoint for more steps and checks it completes without shape/key errors in the
resharding optimizer load path.
Trains a few steps under FSDP, verifies the gathered optimizer state is written next to the
rest of the training state, then resumes from the checkpoint for more steps and checks it
completes without shape/key errors in the FSDP optimizer load path.
"""
# Pre-download dataset to avoid race conditions
download_dataset("lerobot/pusht", episodes=[0])
@@ -220,7 +265,6 @@ class TestMultiGPUTraining:
"--policy.device=cuda",
"--policy.push_to_hub=false",
f"--output_dir={output_dir}",
"--parallelism.dp_shard=2",
"--batch_size=4",
"--steps=10",
"--env_eval_freq=-1",
@@ -230,33 +274,34 @@ class TestMultiGPUTraining:
"--num_workers=0",
]
result = run_accelerate_training(config_args, num_processes=2)
result = run_accelerate_training(
config_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
)
assert result.returncode == 0, (
f"FSDP training failed:\nSTDOUT:\n{result.stdout}\n\nSTDERR:\n{result.stderr}"
)
# Under sharding the optimizer state is written as DCP shards (proves the save
# collective ran); the model artifact stays a gathered model.safetensors at the
# default --checkpoint_format=safetensors.
checkpoint_dir = output_dir / "checkpoints" / "last"
training_state_dir = checkpoint_dir / "training_state"
optimizer_shards = training_state_dir / "optimizer_0"
assert optimizer_shards.is_dir(), f"FSDP optimizer shards not saved in {training_state_dir}"
assert any(optimizer_shards.iterdir()), f"FSDP optimizer shard dir is empty: {optimizer_shards}"
assert (checkpoint_dir / "pretrained_model" / "model.safetensors").exists(), (
f"Gathered model weights not saved in {checkpoint_dir}"
# The gathered optimizer state must be written under FSDP (proves the save collective ran),
# in the same safetensors format as single-GPU training.
training_state_dir = output_dir / "checkpoints" / "last" / "training_state"
optimizer_state = training_state_dir / "optimizer_state.safetensors"
optimizer_param_groups = training_state_dir / "optimizer_param_groups.json"
assert optimizer_state.exists(), f"FSDP optimizer state not saved in {training_state_dir}"
assert optimizer_param_groups.exists(), (
f"FSDP optimizer param groups not saved in {training_state_dir}"
)
# Resume from the checkpoint for more steps. A successful run proves the DCP optimizer
# load accepts the saved state and reshards it without shape/key errors. The topology
# is restored from train_config.json, so --parallelism.* is not repeated here.
resume_config = checkpoint_dir / "pretrained_model" / "train_config.json"
# Resume from the checkpoint for more steps. A successful run proves load_fsdp_optimizer
# accepts the saved state and reshards it without shape/key errors.
resume_config = output_dir / "checkpoints" / "last" / "pretrained_model" / "train_config.json"
resume_args = [
f"--config_path={resume_config}",
"--resume=true",
"--steps=20",
]
resume_result = run_accelerate_training(resume_args, num_processes=2)
resume_result = run_accelerate_training(
resume_args, num_processes=2, temp_dir=temp_dir, distributed_type="FSDP"
)
assert resume_result.returncode == 0, (
f"FSDP resume failed:\nSTDOUT:\n{resume_result.stdout}\n\nSTDERR:\n{resume_result.stderr}"
)
-111
View File
@@ -1,111 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import doctest
from lerobot.utils.doctest_utils import LeRobotDocTestParser, preprocess_string
# An example with expected output, formatted the way ruff's `docstring-code-format` leaves it: no blank
# line between the last output line and the closing fence. This is the exact shape that breaks stdlib.
FORMATTED_EXAMPLE = """Summary.
Example:
```python
>>> 1 + 1
2
```
"""
def test_stdlib_parser_swallows_the_closing_fence():
"""Guards the premise of the port: without the patch, the fence lands in the expected output.
Uses the base class rather than `doctest.DocTestParser`, which the root `conftest.py` has already
replaced with ours by the time this runs.
"""
stdlib_parser = LeRobotDocTestParser.__bases__[0]()
(example,) = (e for e in stdlib_parser.parse(FORMATTED_EXAMPLE) if isinstance(e, doctest.Example))
assert "```" in example.want
def test_parser_stops_at_the_closing_fence():
"""The whole reason `LeRobotDocTestParser` exists: `want` must be the output and nothing else."""
(example,) = (
e for e in LeRobotDocTestParser().parse(FORMATTED_EXAMPLE) if isinstance(e, doctest.Example)
)
assert example.source == "1 + 1\n"
assert example.want == "2\n"
def test_example_with_output_passes_end_to_end():
"""A formatted example with output should actually run green."""
runner = doctest.DocTestRunner()
test = LeRobotDocTestParser().get_doctest(FORMATTED_EXAMPLE, {}, "formatted", None, 0)
results = runner.run(test, out=lambda _: None)
assert results.failed == 0
assert results.attempted == 1
def test_noisy_calls_get_ignore_result():
string = """
```python
>>> ds = load_dataset("lerobot/pusht")
```
"""
assert "# doctest: +IGNORE_RESULT" in preprocess_string(string, False, False)
def test_ignore_result_is_not_added_twice():
string = """
```python
>>> ds = load_dataset("lerobot/pusht") # doctest: +IGNORE_RESULT
```
"""
assert preprocess_string(string, False, False).count("# doctest: +IGNORE_RESULT") == 1
def test_cuda_examples_are_dropped_when_requested():
string = """
```python
>>> model.to("cuda")
```
"""
assert preprocess_string(string, True, False) == ""
assert preprocess_string(string, False, False) != ""
def test_hardware_examples_are_dropped_when_requested():
"""Serial ports, connect calls and Hub downloads all need real resources."""
for source in [
'>>> robot = SO101Follower(SO101FollowerConfig(port="/dev/ttyACM0"))',
">>> robot.connect()",
'>>> policy = ACTPolicy.from_pretrained("lerobot/act")',
]:
string = f"""
```python
{source}
```
"""
assert preprocess_string(string, False, True) == "", source
assert preprocess_string(string, False, False) != "", source
def test_plain_examples_survive_both_skips():
string = """
```python
>>> 1 + 1
2
```
"""
assert preprocess_string(string, True, True) == string
-153
View File
@@ -1,153 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Check that every registered hardware config documents the fields users have to get right.
Modelled on `transformers/utils/check_config_docstrings.py`, which checks that every model config links a
checkpoint. LeRobot's equivalent question is the one every new user hits: which port is the device on, and
what happens on calibration. A config that leaves those undocumented sends people to the source.
Only fields the config actually declares are required — a config without a `port` is not asked to document
one.
```bash
python utils/check_config_docstrings.py
```
"""
import inspect
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from check_docstrings import _re_args, _re_parse_arg, find_indent, iter_objects_to_check # noqa: E402
# Fields whose semantics are not obvious from the name and that a user must set correctly on first run.
REQUIRED_FIELDS = ["port"]
# A config must say something about calibration if it participates in it at all.
CALIBRATION_PATTERN = re.compile(r"calibrat", re.IGNORECASE)
MODULES_TO_CHECK = ["lerobot.robots"]
# Configs that document their fields with `#` comments above each field, which doc-builder cannot see.
# Each entry is removed as that config's comments are converted to an `Args:` block.
OBJECTS_TO_IGNORE: set[str] = {
"BiOpenArmFollowerConfig",
"BiRebotB601FollowerConfig",
"BiSOFollowerConfig",
"EarthRoverMiniPlusConfig",
"HopeJrArmConfig",
"HopeJrHandConfig",
"KochFollowerConfig",
"LeKiwiConfig",
"OmxFollowerConfig",
"OpenArmFollowerConfig",
"Reachy2RobotConfig",
"RebotB601FollowerRobotConfig",
"SOFollowerRobotConfig",
}
def documented_args(obj: object) -> set[str]:
"""Return the argument names documented in an object's `Args:` block.
Args:
obj (`object`):
The class to inspect.
Returns:
`set[str]`: The documented argument names, empty if there is no `Args:` section.
"""
doc = getattr(obj, "__doc__", None)
if not doc:
return set()
lines = doc.split("\n")
idx = 0
while idx < len(lines) and _re_args.search(lines[idx]) is None:
idx += 1
if idx == len(lines):
return set()
indent = find_indent(lines[idx])
names = set()
idx += 1
while idx < len(lines) and (len(lines[idx].strip()) == 0 or find_indent(lines[idx]) > indent):
if find_indent(lines[idx]) == indent + 4:
match = _re_parse_arg.search(lines[idx])
if match is not None:
names.add(match.groups()[1])
idx += 1
return names
def check_config_docstrings() -> list[str]:
"""Check every registered config in `MODULES_TO_CHECK`.
Returns:
`list[str]`: One message per config that is missing a required field or calibration semantics.
"""
from lerobot.robots import RobotConfig
failures = []
for module_name in MODULES_TO_CHECK:
for obj in iter_objects_to_check(module_name):
if not inspect.isclass(obj) or not issubclass(obj, RobotConfig) or obj is RobotConfig:
continue
if inspect.isabstract(obj) or obj.__qualname__ in OBJECTS_TO_IGNORE:
continue
try:
fields = set(inspect.signature(obj).parameters)
except (TypeError, ValueError):
continue
doc = getattr(obj, "__doc__", "") or ""
documented = documented_args(obj)
name = f"{obj.__module__}.{obj.__qualname__}"
for field in REQUIRED_FIELDS:
if field in fields and field not in documented:
failures.append(f"{name}: does not document `{field}`")
if "calibration_dir" in fields and CALIBRATION_PATTERN.search(doc) is None:
failures.append(f"{name}: says nothing about calibration")
return failures
def main() -> int:
"""Run the check.
Returns:
`int`: `0` when every registered config is documented, `1` otherwise.
"""
failures = check_config_docstrings()
if failures:
print(
"The following robot configs are missing documentation a user needs on first run. See "
"docs/source/writing_docstrings.mdx:",
file=sys.stderr,
)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-566
View File
@@ -1,566 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Check that documented arguments match the real signature.
Adapted from the core of `transformers/utils/check_docstrings.py`. The parts of that file bound to
transformers internals — the `@auto_docstring` decorator system, modular-file propagation, `ModelArgs`,
GitPython — are deliberately not ported.
What this enforces, for every public object in `MODULES_TO_CHECK`:
- every parameter in the signature has an `Args:` entry, in signature order;
- no `Args:` entry names a parameter that does not exist;
- the `*optional*, defaults to `X`` clause matches the real default.
That last one is why the clause is not decorative. See docs/source/writing_docstrings.mdx.
Check, as CI does:
```bash
python utils/check_docstrings.py
```
Rewrite the `Args:` blocks to match the signatures, inserting `<fill_docstring>` placeholders for
parameters that are missing entirely:
```bash
python utils/check_docstrings.py --fix_and_overwrite
```
`MODULES_TO_CHECK` is the ratchet: add a module once its docstrings are converted.
"""
import argparse
import ast
import enum
import importlib
import inspect
import operator as op
import pkgutil
import re
import sys
from pathlib import Path
from typing import Any
PATH_TO_REPO = Path(__file__).resolve().parent.parent
PATH_TO_LEROBOT = PATH_TO_REPO / "src" / "lerobot"
# Modules whose public objects are checked. Add a module here once its docstrings follow the standard.
MODULES_TO_CHECK = [
"lerobot.robots",
]
# Objects that do not yet follow the standard, so the check can be green from day one. Removing an entry
# and running `--fix_and_overwrite` is how a module gets converted.
#
# Every entry below has a bare `Attributes:` section, which this checker reads as an argument section (the
# same aliasing doc-builder does) and therefore compares against the signature. They are converted in the
# docstring PR that follows this one, which empties this set.
OBJECTS_TO_IGNORE: set[str] = {
"ChannelFactoryInitialize",
"EarthRoverMiniPlus",
"EarthRoverMiniPlusConfig",
"EEBoundsAndSafety",
"EEReferenceAndDelta",
"ForwardKinematicsJointsToEEAction",
"ForwardKinematicsJointsToEEObservation",
"GripperVelocityToJoint",
"InverseKinematicsEEToJoints",
"Robot",
}
OPTIONAL_KEYWORD = "*optional*"
_re_args = re.compile(r"^\s*(Args?|Arguments?|Attributes?|Params?|Parameters?):\s*$")
_re_parse_arg = re.compile(r"^(\s*)(\S+)\s+\((.+)\)(?:\:|$)")
_re_parse_description = re.compile(r"\*optional\*, defaults to (.*)$")
MATH_OPERATORS = {
ast.Add: op.add,
ast.Sub: op.sub,
ast.Mult: op.mul,
ast.Div: op.truediv,
ast.Pow: op.pow,
ast.BitXor: op.xor,
ast.USub: op.neg,
}
def find_indent(line: str) -> int:
"""Return the number of spaces a line is indented by.
Args:
line (`str`):
The line to measure.
Returns:
`int`: The indentation width.
"""
search = re.search(r"^(\s*)(?:\S|$)", line)
return 0 if search is None else len(search.groups()[0])
def is_dataclass_factory_default(default: Any) -> bool:
"""Whether a signature default came from a dataclass `field(default_factory=...)`.
`inspect.signature` renders those as a `<factory>` sentinel, which must not be written into a
docstring as a literal default.
Args:
default (`Any`):
The default value taken from the signature.
Returns:
`bool`: `True` for the factory sentinel.
"""
return repr(default) == "<factory>"
def stringify_default(default: Any) -> str:
"""Render a default value the way a docstring should show it.
Args:
default (`Any`):
The default value to process.
Returns:
`str`: Numbers are left bare, everything else is wrapped in backticks.
"""
if isinstance(default, bool):
# Must precede the int check: a bool passes isinstance(x, int).
return f"`{default}`"
elif isinstance(default, enum.Enum):
# Must also precede the int check: an IntEnum passes isinstance(x, int).
return f"`{str(default)}`"
elif isinstance(default, int):
return str(default)
elif isinstance(default, float):
result = str(default)
return str(round(default, 2)) if len(result) > 6 else result
elif isinstance(default, str):
return str(default) if default.isnumeric() else f'`"{default}"`'
elif isinstance(default, type):
return f"`{default.__name__}`"
else:
return f"`{default}`"
def eval_node(node):
"""Evaluate one node of a arithmetic-only AST.
Args:
node (`ast.AST`):
The node to evaluate.
Returns:
`float | int | complex`: The node's value.
Raises:
TypeError: If the node is not a number or a supported arithmetic operation.
"""
if isinstance(node, ast.Constant) and type(node.value) in (int, float, complex):
return node.value
elif isinstance(node, ast.BinOp):
return MATH_OPERATORS[type(node.op)](eval_node(node.left), eval_node(node.right))
elif isinstance(node, ast.UnaryOp):
return MATH_OPERATORS[type(node.op)](eval_node(node.operand))
else:
raise TypeError(node)
def eval_math_expression(expression: str) -> float | int | None:
"""Safely evaluate an arithmetic expression found in a docstring.
Docstrings often document a default as an expression (`1 / 255` is the classic), which should be left
alone rather than replaced by its computed value.
Args:
expression (`str`):
The expression to evaluate.
Returns:
`float | int | None`: The value, or `None` if it is not a plain arithmetic expression.
"""
try:
return eval_node(ast.parse(expression, mode="eval").body)
except (TypeError, SyntaxError, KeyError, ZeroDivisionError):
return None
def replace_default_in_arg_description(description: str, default: Any) -> str:
"""Rewrite the `*optional*, defaults to X` clause of one argument description.
Args:
description (`str`):
The argument description from the docstring, without the name.
default (`Any`):
The real default from the signature, or `inspect._empty` if the argument is required.
Returns:
`str`: The description with its optional/default clause matching the signature.
"""
# Plenty of docstrings use `optional` or **optional** instead of *optional*.
description = description.replace("`optional`", OPTIONAL_KEYWORD)
description = description.replace("**optional**", OPTIONAL_KEYWORD)
if default is inspect._empty:
# Required: the description must not claim otherwise.
idx = description.find(OPTIONAL_KEYWORD)
if idx != -1:
description = description[:idx].rstrip().removesuffix(",").rstrip()
elif default is None or is_dataclass_factory_default(default):
# A `None` default is not spelled out, and a `default_factory` has no literal value to show.
idx = description.find(OPTIONAL_KEYWORD)
if idx == -1:
description = f"{description}, {OPTIONAL_KEYWORD}"
elif re.search(r"defaults to `?None`?", description) is not None:
description = description[: idx + len(OPTIONAL_KEYWORD)]
else:
str_default = None
documented_match = re.search("defaults to `?(.*?)(?:`|$)", description)
if isinstance(default, (int, float)) and documented_match is not None:
documented = documented_match.groups()[0]
if default == eval_math_expression(documented):
try:
# Directly convertible means it was a plain literal.
str_default = str(type(default)(documented))
except (TypeError, ValueError):
# Otherwise it was an expression; keep it as written.
str_default = f"`{documented}`"
if str_default is None:
str_default = stringify_default(default)
if OPTIONAL_KEYWORD not in description:
description = f"{description}, {OPTIONAL_KEYWORD}, defaults to {str_default}"
elif _re_parse_description.search(description) is None:
idx = description.find(OPTIONAL_KEYWORD)
description = f"{description[: idx + len(OPTIONAL_KEYWORD)]}, defaults to {str_default}"
else:
description = _re_parse_description.sub(f"*optional*, defaults to {str_default}", description)
return description
def get_default_description(arg: inspect.Parameter) -> str:
"""Build the parenthesised type-and-default part for an undocumented parameter.
Args:
arg (`inspect.Parameter`):
The parameter to describe.
Returns:
`str`: Something like ``` `int`, *optional*, defaults to 3 ```.
"""
if arg.annotation is inspect._empty:
arg_type = "<fill_type>"
elif hasattr(arg.annotation, "__name__"):
arg_type = arg.annotation.__name__
else:
arg_type = str(arg.annotation)
if arg.default is inspect._empty:
return f"`{arg_type}`"
elif arg.default is None or is_dataclass_factory_default(arg.default):
return f"`{arg_type}`, {OPTIONAL_KEYWORD}"
else:
return f"`{arg_type}`, {OPTIONAL_KEYWORD}, defaults to {stringify_default(arg.default)}"
def find_source_file(obj: Any) -> Path:
"""Locate the file an object is defined in.
Args:
obj (`Any`):
The object to locate.
Returns:
`Path`: The source file.
"""
obj_file = PATH_TO_LEROBOT
for part in obj.__module__.split(".")[1:]:
obj_file = obj_file / part
return obj_file.with_suffix(".py")
def match_docstring_with_signature(obj: Any) -> tuple[str, str] | None:
"""Compare an object's documented arguments against its signature.
Dataclasses need no special handling: `inspect.signature` resolves the generated `__init__`, inherited
fields included, which is exactly the set a reader sees on the rendered page.
Args:
obj (`Any`):
The class or function to check.
Returns:
`tuple[str, str] | None`: The current `Args:` block and the one matching the signature, or `None`
when there is nothing to compare — no docstring, no documented arguments, or an unsupported
signature.
"""
if not getattr(obj, "__doc__", None):
return None
try:
source, _ = inspect.getsourcelines(obj)
except (OSError, TypeError):
source = []
idx = 0
while idx < len(source) and '"""' not in source[idx]:
idx += 1
ignore_order = False
if idx < len(source) and idx > 0:
line_before_docstring = source[idx - 1]
if re.search(r"^\s*#\s*no-format\s*$", line_before_docstring):
return None
elif re.search(r"^\s*#\s*ignore-order\s*$", line_before_docstring):
ignore_order = True
try:
signature = inspect.signature(obj).parameters
except (ValueError, TypeError):
return None
obj_doc_lines = obj.__doc__.split("\n")
idx = 0
while idx < len(obj_doc_lines) and _re_args.search(obj_doc_lines[idx]) is None:
idx += 1
if idx == len(obj_doc_lines):
# No arguments documented; coverage is interrogate's job, not this check's.
return None
if "kwargs" in signature and signature["kwargs"].annotation != inspect._empty:
# Typed **kwargs are not introspectable in a useful way here.
return None
indent = find_indent(obj_doc_lines[idx])
arguments: dict[str, Any] = {}
current_arg = None
idx += 1
start_idx = idx
# Consume until a non-empty line returns to the section's own indent, or the docstring ends.
while idx < len(obj_doc_lines) and (
len(obj_doc_lines[idx].strip()) == 0 or find_indent(obj_doc_lines[idx]) > indent
):
if find_indent(obj_doc_lines[idx]) == indent + 4:
re_search_arg = _re_parse_arg.search(obj_doc_lines[idx])
if re_search_arg is not None:
_, name, description = re_search_arg.groups()
current_arg = name
if name in signature:
default = signature[name].default
if signature[name].kind is inspect._ParameterKind.VAR_KEYWORD:
default = None
new_description = replace_default_in_arg_description(description, default)
else:
new_description = description
arguments[current_arg] = [
_re_parse_arg.sub(rf"\1\2 ({new_description}):", obj_doc_lines[idx])
]
elif current_arg is not None:
arguments[current_arg].append(obj_doc_lines[idx])
idx += 1
# Walk back over the trailing blank lines we consumed.
idx -= 1
if current_arg:
while len(obj_doc_lines[idx].strip()) == 0:
arguments[current_arg] = arguments[current_arg][:-1]
idx -= 1
idx += 1
old_doc_arg = "\n".join(obj_doc_lines[start_idx:idx])
old_arguments = list(arguments.keys())
arguments = {name: "\n".join(doc) for name, doc in arguments.items()}
for name in set(signature.keys()) - set(arguments.keys()):
arg = signature[name]
# Private parameters and *args/**kwargs are only documented if the author chose to.
if name.startswith("_") or arg.kind in [
inspect._ParameterKind.VAR_KEYWORD,
inspect._ParameterKind.VAR_POSITIONAL,
]:
arguments[name] = ""
else:
arguments[name] = (
" " * (indent + 4) + f"{name} ({get_default_description(arg)}): <fill_docstring>"
)
if ignore_order:
new_param_docs = [arguments[name] for name in old_arguments if name in signature]
missing = set(signature.keys()) - set(old_arguments)
new_param_docs.extend([arguments[name] for name in missing if len(arguments[name]) > 0])
else:
new_param_docs = [arguments[name] for name in signature if len(arguments[name]) > 0]
return old_doc_arg, "\n".join(new_param_docs)
def fix_docstring(obj: Any, old_doc_args: str, new_doc_args: str) -> None:
"""Rewrite an object's `Args:` block in its source file.
Args:
obj (`Any`):
The object whose docstring is being fixed.
old_doc_args (`str`):
The current `Args:` block, as returned by [`match_docstring_with_signature`].
new_doc_args (`str`):
The replacement block, as returned by [`match_docstring_with_signature`].
Raises:
ValueError: If the block found in the source does not match the one parsed from `__doc__`, which
means the boundaries were identified wrongly and rewriting would corrupt the file.
"""
source, line_number = inspect.getsourcelines(obj)
idx = 0
while idx < len(source) and _re_args.search(source[idx]) is None:
idx += 1
if idx == len(source):
# Inherited docstring: do not rewrite it on the child.
return
indent = find_indent(source[idx])
idx += 1
start_idx = idx
while idx < len(source) and (len(source[idx].strip()) == 0 or find_indent(source[idx]) > indent):
idx += 1
idx -= 1
while len(source[idx].strip()) == 0:
idx -= 1
idx += 1
# `old_doc_args` comes from `__doc__`, whose indentation differs from the raw source lines.
source_args_as_str = "".join(source[start_idx:idx])
if inspect.cleandoc(source_args_as_str) != inspect.cleandoc(old_doc_args):
raise ValueError(
f"Cannot fix the docstring of {obj.__name__} in {find_source_file(obj)}: the argument section "
f"in the source does not match the one parsed from __doc__, so the block boundaries are "
f"wrong and rewriting it would corrupt the file.\n\n"
f"Parsed:\n{old_doc_args!r}\n\nFound in source:\n{source_args_as_str.rstrip()!r}\n"
)
obj_file = find_source_file(obj)
lines = obj_file.read_text(encoding="utf-8").split("\n")
# `new_doc_args` is built from `__doc__`, and Python keeps every line after the first at its exact
# source indentation, so the block is already correctly indented for the file. transformers re-indents
# here because its docstrings are often assembled by decorators and no longer match the source.
lines = lines[: line_number + start_idx - 1] + [new_doc_args] + lines[line_number + idx - 1 :]
print(f"Fixing the docstring of {obj.__name__} in {obj_file}.")
obj_file.write_text("\n".join(lines), encoding="utf-8")
def iter_objects_to_check(module_name: str):
"""Yield the public classes and functions defined in a package.
Args:
module_name (`str`):
An importable package name, e.g. `"lerobot.robots"`.
Yields:
`Any`: Each public class or function whose `__module__` is inside the package, deduplicated so
that aliases (`SO101Follower = SOFollower`) are visited once.
"""
package = importlib.import_module(module_name)
module_names = [module_name]
if hasattr(package, "__path__"):
module_names += [
name for _, name, _ in pkgutil.walk_packages(package.__path__, prefix=f"{module_name}.")
]
seen = set()
for name in module_names:
try:
module = importlib.import_module(name)
except Exception as error: # An optional extra is missing; not this check's problem.
print(f"Skipping {name}: {type(error).__name__}: {error}", file=sys.stderr)
continue
for attr_name, obj in vars(module).items():
if attr_name.startswith("_") or not (inspect.isclass(obj) or inspect.isfunction(obj)):
continue
if not getattr(obj, "__module__", "").startswith(module_name):
continue
key = f"{obj.__module__}.{obj.__qualname__}"
if key in seen or obj.__qualname__ in OBJECTS_TO_IGNORE or key in OBJECTS_TO_IGNORE:
continue
seen.add(key)
yield obj
def check_docstrings(overwrite: bool = False) -> list[str]:
"""Check every object in `MODULES_TO_CHECK`.
Args:
overwrite (`bool`, *optional*, defaults to `False`):
Whether to rewrite mismatched `Args:` blocks in place.
Returns:
`list[str]`: The names of objects whose documented arguments do not match their signature. Empty
when everything is consistent.
"""
failures = []
hard_failures = []
for module_name in MODULES_TO_CHECK:
for obj in iter_objects_to_check(module_name):
try:
result = match_docstring_with_signature(obj)
except Exception as error:
hard_failures.append(f"{obj.__qualname__}: {type(error).__name__}: {error}")
continue
if result is None:
continue
old_doc, new_doc = result
if old_doc == new_doc:
continue
if overwrite:
fix_docstring(obj, old_doc, new_doc)
else:
failures.append(f"{obj.__module__}.{obj.__qualname__}")
if hard_failures:
print("The following objects could not be processed:", file=sys.stderr)
for failure in hard_failures:
print(f"- {failure}", file=sys.stderr)
return failures
def main() -> int:
"""Run the check.
Returns:
`int`: `0` when every documented argument matches its signature, `1` otherwise.
"""
parser = argparse.ArgumentParser()
parser.add_argument("--fix_and_overwrite", action="store_true", help="Whether to fix inconsistencies.")
args = parser.parse_args()
failures = check_docstrings(overwrite=args.fix_and_overwrite)
if failures:
print(
"The docstrings of the following objects do not match their signature. Run "
"`make fix-docstrings` to rewrite them, then fill in any `<fill_docstring>` placeholders:",
file=sys.stderr,
)
for failure in failures:
print(f"- {failure}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
-109
View File
@@ -1,109 +0,0 @@
# Copyright 2026 The HuggingFace Inc. team. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Keep the doctest list honest: every path exists, and the file stays sorted.
Adapted from `transformers/utils/check_doctest_list.py`. It is agnostic to whether the list is an allowlist
(what we have now) or a denylist (where transformers ended up), so it survives that inversion unchanged.
Check, as CI does:
```bash
python utils/check_doctest_list.py
```
Sort in place:
```bash
python utils/check_doctest_list.py --fix_and_overwrite
```
"""
import argparse
import sys
from pathlib import Path
REPO_PATH = Path(__file__).resolve().parent.parent
DOCTEST_FILE_PATHS = ["documentation_tests.txt"]
def split_header(lines: list[str]) -> tuple[list[str], list[str]]:
"""Split a list file into its leading comment header and its path entries.
Args:
lines (`list[str]`):
The file's lines, without trailing newlines.
Returns:
`tuple[list[str], list[str]]`: The leading comment/blank lines, and the remaining lines.
"""
for i, line in enumerate(lines):
if line.strip() and not line.lstrip().startswith("#"):
return lines[:i], lines[i:]
return lines, []
def clean_doctest_list(doctest_file: Path, overwrite: bool = False) -> None:
"""Check, and optionally fix, one doctest list file.
Args:
doctest_file (`Path`):
The list file to check or clean.
overwrite (`bool`, *optional*, defaults to `False`):
Whether to fix problems in place. When `False`, raises instead.
Raises:
ValueError: If the file lists a path that does not exist, or is not alphabetically sorted and
`overwrite` is `False`.
"""
lines = doctest_file.read_text(encoding="utf-8").splitlines()
header, entries = split_header(lines)
paths = [line.strip().split(" ")[0] for line in entries if line.strip()]
non_existent = [p for p in paths if not (REPO_PATH / p).exists()]
if non_existent:
listed = "\n".join(f"- {p}" for p in non_existent)
raise ValueError(f"`{doctest_file.name}` contains non-existent paths:\n{listed}")
if paths != sorted(paths):
if not overwrite:
raise ValueError(
f"Files in `{doctest_file.name}` are not in alphabetical order, run "
"`make fix-docstrings` to fix this automatically."
)
doctest_file.write_text("\n".join(header + sorted(paths)) + "\n", encoding="utf-8")
def main() -> int:
"""Run the check over every doctest list file.
Returns:
`int`: A process exit code — `0` when every file is clean, `1` otherwise.
"""
parser = argparse.ArgumentParser()
parser.add_argument("--fix_and_overwrite", action="store_true", help="Whether to fix inconsistencies.")
args = parser.parse_args()
failed = False
for name in DOCTEST_FILE_PATHS:
try:
clean_doctest_list(REPO_PATH / "utils" / name, args.fix_and_overwrite)
except ValueError as error:
print(error, file=sys.stderr)
failed = True
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
-12
View File
@@ -1,12 +0,0 @@
# Files whose docstring examples are executed by `make doctest`.
#
# This is an ALLOWLIST: only the paths below are collected. transformers started the same way and has since
# inverted to a denylist (`utils/not_doctested.txt`), which is the better end state — it makes a new file
# tested by default. LeRobot cannot start there: at the time of writing, public docstring coverage is under
# 50% and only a handful of files carry any example at all, so a denylist would need hundreds of entries on
# day one and would say nothing about what is actually verified.
#
# Invert once coverage is high enough that the exclusions are the short list.
# `utils/check_doctest_list.py` does not care which way round it is.
#
# Keep alphabetically sorted: `make check-doctest-list` enforces it, `make fix-docstrings` sorts it.