Compare commits

...

15 Commits

Author SHA1 Message Date
Pepijn a47e535b02 fix(smolvla2): per-recipe inference prompts to match training shape
The four high-level steps shared one generic
``_control_context_messages`` that jammed task + plan + memory +
completed_subtask into a single user message. The recipes in
``smolvla2_hirobot.yaml`` each have a *specific* multi-message layout
(``memory_update``: ``user(task) → assistant(prev memory) →
user(completed subtask)``; ``high_level_subtask``: ``user(task+plan+
memory) → user(current subtask)``; ``user_interjection_response``:
``user(task) → assistant(prev plan) → user(interjection)``). After
``apply_chat_template`` those layouts produce different prompts than
the runtime's flattened single-user-turn version, and the model fell
back to its dominant training mode (VQA JSON output) — generating
``":":":":":":...`` repetition.

Add four per-recipe prompt builders (``_msgs_for_subtask``,
``_msgs_for_memory``, ``_msgs_for_interjection``, ``_msgs_for_vqa``),
each mirroring its sub-recipe's exact message structure including
the ``if_present`` skips. Wire each high-level step to its matching
builder. Inference prompts now line up with what the model saw in
training, so generation should produce coherent text instead of
repeated tokens.

Generic ``_control_context_messages`` is kept (still used by tests
and the no-recipe fallback path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:47:22 +02:00
Pepijn 6d9b431b54 fix(smolvla2): match training's text-loss forward in select_message
Previous rewrite drove generation through ``vlm.generate()`` (the
standard SmolVLM path), which ignores SmolVLA's custom ``embed_prefix``
that interleaves images + lang + state. Result: the model received a
prompt format it had never been trained on at inference and emitted
JSON-fragment gibberish (``" " " ,",","`` ``cube lift {"...``).

Revert to the cumulative-buffer AR loop driven through
``vlm_with_expert.forward`` — the *same* forward call ``_compute_text_loss``
makes during training (``inputs_embeds=[prefix_embs, None],
use_cache=False, fill_kv_cache=True``). With ``fill_kv_cache=True``,
every layer routes through ``forward_attn_layer``, which gracefully
skips ``None`` expert inputs (``if hidden_states is None or layer is
None: continue``); cross-attention layers — which would otherwise hard-
require a non-None expert input — are bypassed entirely.

Inference now sees the same prefix structure as training: images +
lang + state, with new tokens appended to the lang region. The text
distribution matches what the model was trained to produce.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:42:15 +02:00
Pepijn 347e706326 fix(smolvla2): drop pixel_values from select_message generate path
SmolVLA's image preprocessor sizes frames to whatever the action
expert was trained on, but SmolVLM's standard vision tower expects
its own default tile grid (e.g. 384/14 → 27×27 patches). The
mismatch surfaces deep in the post-vision reshape as
``RuntimeError: shape '[2, 34, 34, 768]' is invalid for input of
size 1843200`` — the model has 1200 patches but expects 34×34=1156.

Drop ``pixel_values`` from ``vlm.generate(...)`` so SmolVLM runs as
a text-only LM at REPL time. The high-level branches (subtask /
plan / memory) are dominated by their text context anyway, so this
is acceptable for dry-run inference. VQA loses its image grounding
— that will be marked as expected for the dry-run path until a
follow-up either re-processes images through SmolVLM's own
``ImageProcessor`` to match its tile grid, or gives
``vlm_with_expert`` a real AR text decode mode that handles state
and image embeddings the way training does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 13:36:53 +02:00
Pepijn fa8ae1e89b fix(smolvla2): drive select_message through SmolVLM.generate
The hand-rolled AR loop in ``select_message`` was fighting the
underlying ``vlm_with_expert.forward`` design, which assumes the
"prefix-once + suffix-always-via-expert" pattern that ``denoise_step``
uses for action chunks. Cross-attn layers (every other layer with
``attention_mode='cross_attn'`` + ``self_attn_every_n_layers=2``)
hard-require an expert input on every call: passing
``inputs_embeds=[current_embs, None]`` crashed at
``expert_layer.input_layernorm(None)`` with ``'NoneType' object has
no attribute 'dtype'``. Earlier KV-cache attempts ran into the
matching ``[15, 139] vs [15, 1]`` shape mismatch because the cache
gets *overwritten*, not appended, on each ``fill_kv_cache=True`` call
— there's just no AR-text-decode mode in this forward.

Stop fighting it: drive AR text generation through the underlying
SmolVLM via ``vlm.generate(input_ids=..., attention_mask=...,
pixel_values=...)``. KV caching, sampling/greedy, EOS handling all
come from HF's standard implementation. Trade-off: ``state`` drops
out of the prefix at inference (no slot for it on the standard
SmolVLM path), so high-level generations may drift from training
distribution slightly. That's acceptable for the dry-run REPL — the
high-level branches (subtask / plan / memory / vqa) are mostly
vision+language conditioned anyway, and the action expert (where
state actually matters) goes through the unchanged ``select_action``
path.

Image features the runtime merged in (``observation.images.*``) are
stacked into the ``[B, num_images, C, H, W]`` shape SmolVLM expects.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:39:34 +02:00
Pepijn 3ff6c6860e fix(smolvla2): rewrite select_message decode loop without KV cache
SmolVLA's ``vlm_with_expert.forward`` doesn't actually support
incremental KV cache growth — its only ``fill_kv_cache=True`` mode
*overwrites* the cache with the latest call's key/value states, and
its only ``fill_kv_cache=False`` mode concatenates ``cache + new``
into a local ``key_states`` for one matmul without ever updating the
cache itself. The original ``select_message`` decode loop tried to
use ``fill_kv_cache=True`` per step, which clobbered the cache to
1 token after the first decode and threw
``Expected size for first two dimensions of batch2 tensor to be:
[15, 139] but got: [15, 1]`` — the attention mask still expected
139 keys but the cached + new key_states only had 1.

Match the pattern ``denoise_step`` already uses successfully:
maintain a cumulative ``(embs, pad, att)`` buffer that starts as the
prefix and grows by one bool/embedding row per step. Each step
forwards the *full* sequence with ``use_cache=False,
fill_kv_cache=False, past_key_values=None`` so the matmul shapes
always line up. Generated-token rows are tagged ``pad=1, att=1``
which makes them fully causal among themselves while still able to
attend back to the entire prefix (per ``make_att_2d_masks``
semantics: a token can attend to any earlier token whose cumulative
``att`` count is ≤ its own).

Image encoding is still done once via the initial ``embed_prefix``
call — the expensive part doesn't repeat. The remaining cost is
O(n²) text-only transformer forwards, which is fine for the dry-run
REPL's 50–100 token responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:15:28 +02:00
Pepijn fd89efb545 fix(smolvla2): 3D attention mask in select_message decode loop
SmolVLA's ``eager_attention_forward`` does
``masked = torch.where(attention_mask[:, None, :, :], ...)``, which
requires a 3D ``[B, query_len, key_len]`` bool tensor so the
broadcast to 4D works. ``select_message``'s prefix forward got this
right (passes ``prefix_2d`` from ``make_att_2d_masks``), but the
KV-cache decoding loop built ``new_attn = torch.ones((bsize,
cur_pos + 1))`` — 2D — and the very first decode step blew up with
``IndexError: too many indices for tensor of dimension 2``.

During KV-cache decoding ``query_len = 1`` and
``key_len = cur_pos + 1`` (prefix + every token already generated),
so the right shape is ``[B, 1, cur_pos + 1]``. Match the layout
SmolVLA's working ``denoise_step`` uses for the equivalent
``prefix_pad_2d_masks`` build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:08:52 +02:00
Pepijn 2776b57c9e fix(smolvla2): bool attention mask + clean Claude-Code-style REPL
Two issues that combined to make the REPL unusable:

1. ``BatchEncoding.attention_mask`` is a ``Long`` tensor, but SmolVLA's
   ``eager_attention_forward`` does
   ``torch.where(attention_mask[..., None, :, :], ...)`` which
   requires a *bool* condition. Every forward raised ``where expected
   condition to be a boolean tensor, but got a tensor with dtype Long``
   and the diagnostic surfaced it cleanly in the REPL — but generation
   produced nothing useful. Cast to ``bool`` in ``_build_text_batch``
   so the prefix forward goes through.

2. The interactive REPL used ``rich.live.Live`` panels stacked on top
   of ``logging.basicConfig(level=DEBUG)`` HTTP request lines from
   ``httpcore`` / ``httpx`` / ``huggingface_hub``. The two rendering
   loops fought each other in the user's terminal and the output was
   illegible: hundreds of debug lines interleaved with re-rendered
   panels.

   Replace ``Live`` with a simple block redraw — clear screen, print
   the state block, print any robot log lines, then a single ``> ``
   prompt. State changes are visible above the prompt, the way Claude
   Code's REPL renders. No flicker, no re-render races.

   ``_silence_noisy_loggers`` drops the chatty third-party HTTP /
   download / model-init loggers to WARNING. ``-v`` still enables
   DEBUG on the lerobot loggers; if the user needs the HTTP traces,
   they can flip those individually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:03:47 +02:00
Pepijn 0fb5f04965 fix(smolvla2): handle BatchEncoding return from apply_chat_template
``tokenizer.apply_chat_template(..., tokenize=True, return_tensors='pt')``
on newer transformers returns a ``BatchEncoding`` (dict-like) rather
than a raw ``Tensor`` — particularly when the underlying call routes
through a processor. ``_build_text_batch`` only handled the ``Tensor``
and ``list`` shapes, so the encoding object reached SmolVLA's
``embed_language_tokens`` and ``F.embedding`` blew up with
``argument 'indices' must be Tensor, not BatchEncoding`` on every
high-level forward.

Normalise the return:
  * ``BatchEncoding`` / ``dict`` → take ``input_ids`` (and the encoder's
    ``attention_mask`` when present, since ``pad_token_id`` can be
    ``None`` for SmolVLM and the fall-back ``ids != pad_token_id``
    breaks then),
  * ``list[int]`` / ``list[list[int]]`` → wrap in a long tensor,
  * ``Tensor`` → keep as-is.

After unwrapping, ensure shape ``(1, seq)`` and that ``attention_mask``
is a tensor on the same device as ``ids``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:59:57 +02:00
Pepijn 7296ac97af fix(smolvla2): make silent generation failures visible in REPL
Two failure modes were combining to make the runtime "look dead":

1. ``_build_text_batch`` produced lang tokens via
   ``apply_chat_template(return_tensors='pt')`` on CPU, but the policy
   sits on the configured device (mps / cuda). The first prefix-embed
   inside ``select_message`` then raised a device-mismatch on every
   call. The bare ``except Exception`` in ``_generate_with_policy``
   swallowed it at debug level — no logs, no chat output, no visible
   sign anything had run.

2. Even when generation succeeded but returned an empty string
   (greedy EOS, unhappy chat template, etc.), the high-level steps
   silently no-op'd, so users saw nothing.

Move tokens to ``policy.config.device`` in ``_build_text_batch`` so
the prefix forward succeeds in the common case. Bump the swallowing
log level to ``warning`` (with optional traceback under ``-v``), and
when ``state`` is given route the same diagnostic into the REPL log
via ``push_log`` so the user sees ``[warn] subtask gen failed: ...``
inline. Also push an ``[info] ... produced no text this tick`` line
when generation runs but yields nothing, so empty completions are
distinguishable from "step never ran". Apply the same surface to
``LowLevelForward.select_action`` failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:47:34 +02:00
Pepijn 9cbbcfb6a2 fix(smolvla2): tokenize lang prompt inline before select_action
LowLevelForward was handing the observation provider's output straight
to ``policy.select_action``, but SmolVLA's ``_get_action_chunk``
indexes ``batch[OBS_LANGUAGE_TOKENS]`` and crashes with ``KeyError:
'observation.language.tokens'`` when the key isn't there. Our provider
deliberately strips the dataset's language columns (the runtime drives
messages itself), so nothing else was producing those tokens — the
chunk path crashed on the very first tick after task was set.

Build a low-level prompt from current runtime state inline (task /
plan / memory as the user turn, current subtask appended as a
continuation assistant turn when known), tokenize it with the same
helper the high-level steps use, and merge ``lang_tokens`` /
``lang_masks`` into the observation before the call. Skip the step
when no task is set yet, and swallow ``select_action`` exceptions at
debug level so a missing observation feature doesn't kill the REPL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:40:18 +02:00
Pepijn fea41b29f5 fix(datasets): probe parquet for language columns before strict cast
``_load_hf_dataset`` was building the strict cast schema only from
``meta/info.json["features"]``. Datasets annotated by
``lerobot-annotate`` but still tagged at the older codebase version
(no ``language_persistent`` / ``language_events`` entry in
``info.json``) carry both columns in the parquet itself but not in the
features dict, so ``Dataset.from_parquet`` blew up with
``CastError: column names don't match`` when trying to project a
9-column parquet onto a 7-column schema.

Probe one parquet shard's actual schema; if either language column is
present in the parquet but missing from ``features``, graft it on
using PR 1's ``language_persistent_column_feature`` /
``language_events_column_feature`` helpers. No-op when neither column
is present (fully backwards-compatible with v3.0 datasets), no-op when
both are already registered (fully forwards-compatible with future
v3.1 ``info.json`` writes).

This unblocks dry-run inference on PR 2-annotated datasets that
weren't re-tagged to v3.1 — including the ones in the field today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:31:19 +02:00
Pepijn 7b4d281ef5 fix(smolvla2): build preprocessor fresh, don't round-trip the recipe
``PolicyProcessorPipeline.from_pretrained`` reconstructs each saved
step by passing the persisted JSON config back to ``__init__``, but
``RenderMessagesStep.recipe`` (a ``TrainingRecipe``) doesn't survive
the JSON round-trip — the saved entry is ``{}`` and the reconstructor
crashes with ``missing 1 required argument: 'recipe'``.

Bypass the round-trip in the runtime CLI by passing
``pretrained_path=None`` to ``make_pre_post_processors``. That re-runs
``make_smolvla2_pre_post_processors``, which reloads the recipe YAML
referenced by ``cfg.recipe_path`` and wires it back into the step
correctly. ``NormalizerProcessorStep`` still gets stats from
``ds_meta.stats`` so normalization matches training.

Proper fix is to make ``RenderMessagesStep`` serializable (e.g. by
persisting the recipe path / contents); this commit keeps it scoped to
the runtime path so dry-run testing isn't blocked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:27:12 +02:00
Pepijn 29bb8bb20e fix(tools): unblock pocket-tts resolution (>=1.0.0,<3.0.0)
The previous bound `>=0.1.0,<1.0.0` matched zero published versions —
pocket-tts went straight to 1.0.0 on PyPI, with 0.x never released.
That made `uv sync --extra tools` (and any sync that pulls the `dev` /
`all` superset) fail with "requirements are unsatisfiable" on every
Python version uv tried, including 3.12.

Bump to `>=1.0.0,<3.0.0` so 1.x and 2.x are reachable. SayTool only
touches `TTSModel.load_model()`, `get_state_for_audio_prompt`,
`generate_audio`, and `sample_rate` — small enough surface that 1.x
and 2.x should both work; tighten if a real API break shows up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:15:20 +02:00
Pepijn 3fe686ce9f feat(smolvla2): runtime accepts Hub IDs + dataset-driven dry-run
The runtime CLI's loader was broken — it imported a `make_policy_from_path`
that doesn't exist in `lerobot.policies.factory` — and the high-level text
steps generated plan / subtask / memory / VQA from a text-only batch with
no images or state, so dry-runs drifted from the training distribution.

Switch to the standard `PreTrainedConfig.from_pretrained` +
`make_policy(cfg, ds_meta=...)` flow so `--policy.path` accepts both local
directories and Hub repo ids, and add a `--dataset.repo_id` path that walks
a chosen episode and feeds preprocessed observations into every forward
pass — including the four high-level steps (`HighLevelSubtaskFwd`,
`MemoryUpdateFwd`, `UserInterjectionFwd`, `AskVQAFwd`). Frames are routed
through the saved preprocessor pipeline with `language_persistent` /
`language_events` stripped so the recipe-render step stays a no-op (the
runtime supplies its own messages from current state).

Also wires the rich-based two-zone REPL layout (`ui.py`) that the script
was already importing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 11:09:19 +02:00
pepijn a1b8134ef1 fix(smolvla2): train on rendered language batches
Keep annotated language columns through collation, render batched recipe samples, and make SmolVLA2 text loss robust enough for distributed training on the steerable dataset.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 08:55:56 +00:00
16 changed files with 1150 additions and 209 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ annotations = [
# are isolated so adding a new tool doesn't bloat the base install.
# Currently only `say` (Kyutai pocket-tts; CPU-only, ~100M params).
tools = [
"pocket-tts>=0.1.0,<1.0.0",
"pocket-tts>=1.0.0,<3.0.0",
"scipy>=1.11.0,<2.0.0", # SayTool.output_dir uses scipy.io.wavfile
]
@@ -62,14 +62,14 @@ blend:
ask_vqa_top:
weight: 0.10
bindings:
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.top)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.top)"
vqa_query: "emitted_at(t, style=vqa, role=user, camera=observation.images.front)"
vqa: "emitted_at(t, style=vqa, role=assistant, camera=observation.images.front)"
messages:
- role: user
stream: high_level
if_present: vqa_query
content:
- {type: image, feature: observation.images.top}
- {type: image, feature: observation.images.front}
- {type: text, text: "${vqa_query}"}
- {role: assistant, content: "${vqa}", stream: high_level, target: true, if_present: vqa}
+13 -1
View File
@@ -34,7 +34,6 @@ from .dataset_tools import (
remove_feature,
split_dataset,
)
from .factory import make_dataset, resolve_delta_timestamps
from .image_writer import safe_stop_image_writer
from .io_utils import load_episodes, write_stats
from .language import (
@@ -53,6 +52,19 @@ from .streaming_dataset import StreamingLeRobotDataset
from .utils import DEFAULT_EPISODES_PATH, create_lerobot_dataset_card
from .video_utils import VideoEncodingManager
def make_dataset(*args, **kwargs):
from .factory import make_dataset as _make_dataset
return _make_dataset(*args, **kwargs)
def resolve_delta_timestamps(*args, **kwargs):
from .factory import resolve_delta_timestamps as _resolve_delta_timestamps
return _resolve_delta_timestamps(*args, **kwargs)
# NOTE: Low-level I/O functions (cast_stats_to_numpy, get_parquet_file_size_in_mb, etc.)
# and legacy migration constants are intentionally NOT re-exported here.
# Import directly: ``from lerobot.datasets.io_utils import ...``
+43
View File
@@ -126,10 +126,53 @@ class DatasetReader:
def _load_hf_dataset(self) -> datasets.Dataset:
"""hf_dataset contains all the observations, states, actions, rewards, etc."""
features = get_hf_features_from_features(self._meta.features)
# Datasets annotated with the PR1 language columns may have been
# written without registering those columns in ``meta/info.json``
# (e.g. they predate ``CODEBASE_VERSION="v3.1"`` and were
# back-filled by ``lerobot-annotate``). Probe a single parquet
# shard and graft the column features on so the strict
# ``Dataset.from_parquet`` cast doesn't fail with
# ``column names don't match``.
features = self._extend_features_with_language_columns(features)
hf_dataset = load_nested_dataset(self.root / "data", features=features, episodes=self.episodes)
hf_dataset.set_transform(hf_transform_to_torch)
return hf_dataset
def _extend_features_with_language_columns(
self, features: datasets.Features
) -> datasets.Features:
"""Add ``language_persistent`` / ``language_events`` to ``features``
when the underlying parquet shards declare them but the metadata
doesn't. No-op when neither column is present or both are
already registered.
"""
# Find any one parquet to peek at; bail if there are none yet
# (the dataset will fail later for an unrelated reason and we
# want that error to surface as-is).
try:
sample = next((self.root / "data").glob("*/*.parquet"))
except StopIteration:
return features
from pyarrow import parquet as _pq # noqa: PLC0415
schema_names = set(_pq.read_schema(sample).names)
from .language import ( # noqa: PLC0415
LANGUAGE_EVENTS,
LANGUAGE_PERSISTENT,
language_events_column_feature,
language_persistent_column_feature,
)
extra: dict[str, object] = {}
if LANGUAGE_PERSISTENT in schema_names and LANGUAGE_PERSISTENT not in features:
extra[LANGUAGE_PERSISTENT] = language_persistent_column_feature()
if LANGUAGE_EVENTS in schema_names and LANGUAGE_EVENTS not in features:
extra[LANGUAGE_EVENTS] = language_events_column_feature()
if not extra:
return features
return datasets.Features({**features, **extra})
def _check_cached_episodes_sufficient(self) -> bool:
"""Check if the cached dataset contains all requested episodes and their video files."""
if self.hf_dataset is None or len(self.hf_dataset) == 0:
+16 -2
View File
@@ -64,8 +64,22 @@ def _json_arrow_type() -> pa.DataType:
def _json_feature() -> object:
"""Return the HF ``datasets`` JSON feature, falling back to a string value."""
return datasets.Json() if hasattr(datasets, "Json") else datasets.Value("string")
"""Return the HF feature used for tool-call payloads.
Older ``datasets`` versions do not expose ``datasets.Json``. The
annotation pipeline currently emits the canonical ``say`` tool call
shape, so use that explicit struct instead of falling back to a string
that cannot cast structured parquet values.
"""
if hasattr(datasets, "Json"):
return datasets.Json()
return {
"type": datasets.Value("string"),
"function": {
"name": datasets.Value("string"),
"arguments": {"text": datasets.Value("string")},
},
}
def language_persistent_row_arrow_type() -> pa.StructType:
+2
View File
@@ -26,6 +26,7 @@ from .sac.configuration_sac import SACConfig as SACConfig
from .sac.reward_model.configuration_classifier import RewardClassifierConfig as RewardClassifierConfig
from .sarm.configuration_sarm import SARMConfig as SARMConfig
from .smolvla.configuration_smolvla import SmolVLAConfig as SmolVLAConfig
from .smolvla2.configuration_smolvla2 import SmolVLA2Config as SmolVLA2Config
from .tdmpc.configuration_tdmpc import TDMPCConfig as TDMPCConfig
from .utils import make_robot_action, prepare_observation_for_inference
from .vqbet.configuration_vqbet import VQBeTConfig as VQBeTConfig
@@ -49,6 +50,7 @@ __all__ = [
"SACConfig",
"SARMConfig",
"SmolVLAConfig",
"SmolVLA2Config",
"TDMPCConfig",
"VQBeTConfig",
"WallXConfig",
@@ -99,85 +99,67 @@ class SmolVLA2ChatTokenizerStep(ProcessorStep):
# falls back to whatever ``task`` is in the transition.
return transition
message_streams: list[str | None] = list(comp.get("message_streams") or [])
target_indices: list[int] = sorted(
int(i) for i in (comp.get("target_message_indices") or [])
)
tokenizer = self._get_tokenizer()
text_messages = [_strip_lerobot_blocks(m) for m in messages]
# Tokenize the full chat once.
full_ids = tokenizer.apply_chat_template(
text_messages,
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
if _is_batched_messages(messages):
encoded = [
self._encode_messages(
tokenizer,
msg,
list(streams),
sorted(int(i) for i in indices),
)
for msg, streams, indices in zip(
messages,
comp.get("message_streams") or [[] for _ in messages],
comp.get("target_message_indices") or [[] for _ in messages],
strict=True,
)
]
else:
encoded = [
self._encode_messages(
tokenizer,
messages,
list(comp.get("message_streams") or []),
sorted(int(i) for i in (comp.get("target_message_indices") or [])),
)
]
pad_id = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0
target_length = self.max_length if self.padding == "max_length" else max(
len(ids) for ids, _, _ in encoded
)
if isinstance(full_ids, list) and full_ids and isinstance(full_ids[0], list):
full_ids = full_ids[0]
target_length = min(target_length, self.max_length)
# Build the label mask by re-rendering progressively up to each
# target message and reading off the prefix length. This is the
# robust way to get exact token boundaries: we use the same
# tokenizer, the same ``tools=`` argument, and the same chat
# template — so the prefix tokens are guaranteed to be a prefix
# of the full sequence.
labels = [-100] * len(full_ids)
for tgt in target_indices:
prefix_ids = tokenizer.apply_chat_template(
text_messages[:tgt],
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
)
full_through_target = tokenizer.apply_chat_template(
text_messages[: tgt + 1],
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
)
if isinstance(prefix_ids, list) and prefix_ids and isinstance(prefix_ids[0], list):
prefix_ids = prefix_ids[0]
if (
isinstance(full_through_target, list)
and full_through_target
and isinstance(full_through_target[0], list)
):
full_through_target = full_through_target[0]
start = len(prefix_ids)
end = min(len(full_through_target), len(full_ids))
for pos in range(start, end):
labels[pos] = int(full_ids[pos])
ids_batch = []
attn_batch = []
labels_batch = []
predict_actions = []
for ids, labels, predict_action in encoded:
ids = ids[:target_length]
labels = labels[:target_length]
attn = [1] * len(ids)
if len(ids) < target_length:
n_pad = target_length - len(ids)
ids = ids + [pad_id] * n_pad
labels = labels + [-100] * n_pad
attn = attn + [0] * n_pad
ids_batch.append(ids)
attn_batch.append(attn)
labels_batch.append(labels)
predict_actions.append(predict_action)
# Truncate / pad to ``max_length`` so batches collate cleanly.
# The SmolVLA pipeline downstream relies on a fixed length
# behaviour ("longest" or "max_length") — we mirror it here.
if len(full_ids) > self.max_length:
full_ids = full_ids[: self.max_length]
labels = labels[: self.max_length]
attn = [1] * len(full_ids)
if self.padding == "max_length" and len(full_ids) < self.max_length:
pad_id = (
tokenizer.pad_token_id
if tokenizer.pad_token_id is not None
else 0
)
n_pad = self.max_length - len(full_ids)
full_ids = full_ids + [pad_id] * n_pad
labels = labels + [-100] * n_pad
attn = attn + [0] * n_pad
ids_t = torch.tensor(ids_batch, dtype=torch.long)
attn_t = torch.tensor(attn_batch, dtype=torch.bool)
labels_t = torch.tensor(labels_batch, dtype=torch.long)
predict_actions_t = torch.tensor(predict_actions, dtype=torch.bool)
ids_t = torch.tensor(full_ids, dtype=torch.long)
attn_t = torch.tensor(attn, dtype=torch.bool)
labels_t = torch.tensor(labels, dtype=torch.long)
predict_actions = any(
i < len(message_streams) and message_streams[i] == "low_level"
for i in target_indices
)
if not _is_batched_messages(messages):
ids_t = ids_t.squeeze(0)
attn_t = attn_t.squeeze(0)
labels_t = labels_t.squeeze(0)
predict_actions_t = predict_actions_t.squeeze(0)
new_complementary = dict(comp)
# Drop the per-recipe sidecar keys; everything downstream needs
@@ -194,7 +176,7 @@ class SmolVLA2ChatTokenizerStep(ProcessorStep):
observation[OBS_LANGUAGE_TOKENS] = ids_t
observation[OBS_LANGUAGE_ATTENTION_MASK] = attn_t
new_complementary["text_labels"] = labels_t
new_complementary["predict_actions"] = torch.tensor(predict_actions, dtype=torch.bool)
new_complementary["predict_actions"] = predict_actions_t
new_complementary.pop("task", None)
new_transition = dict(transition)
@@ -202,6 +184,53 @@ class SmolVLA2ChatTokenizerStep(ProcessorStep):
new_transition[TransitionKey.OBSERVATION] = observation
return new_transition
def _encode_messages(
self,
tokenizer: Any,
messages: list[dict[str, Any]],
message_streams: list[str | None],
target_indices: list[int],
) -> tuple[list[int], list[int], bool]:
text_messages = [_strip_lerobot_blocks(m) for m in messages]
full_ids = tokenizer.apply_chat_template(
text_messages,
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
)
full_ids = _as_token_ids(full_ids)
labels = [-100] * len(full_ids)
for tgt in target_indices:
prefix_ids = tokenizer.apply_chat_template(
text_messages[:tgt],
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
)
full_through_target = tokenizer.apply_chat_template(
text_messages[: tgt + 1],
tools=self.tools,
add_generation_prompt=False,
tokenize=True,
return_tensors=None,
)
prefix_ids = _as_token_ids(prefix_ids)
full_through_target = _as_token_ids(full_through_target)
start = len(prefix_ids)
end = min(len(full_through_target), len(full_ids))
for pos in range(start, end):
labels[pos] = int(full_ids[pos])
predict_actions = any(
i < len(message_streams) and message_streams[i] == "low_level"
for i in target_indices
)
return [int(i) for i in full_ids], labels, predict_actions
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
@@ -247,15 +276,11 @@ def _strip_lerobot_blocks(message: dict[str, Any]) -> dict[str, Any]:
continue
if block.get("type") == "text":
text_parts.append({"type": "text", "text": str(block.get("text", ""))})
# If only one text block survives, flatten to a string for
# template friendliness; some chat templates choke on a single-
# element list.
if len(text_parts) == 1:
new["content"] = text_parts[0]["text"]
elif text_parts:
new["content"] = text_parts
else:
new["content"] = ""
new["content"] = text_parts or [{"type": "text", "text": ""}]
elif content is None:
new["content"] = [{"type": "text", "text": ""}]
else:
new["content"] = [{"type": "text", "text": str(content)}]
if "tool_calls" in new and not new["tool_calls"]:
# Drop empty tool_calls — some templates render them as a
# spurious empty marker.
@@ -267,5 +292,19 @@ def _strip_lerobot_blocks(message: dict[str, Any]) -> dict[str, Any]:
return new
def _is_batched_messages(messages: Any) -> bool:
return isinstance(messages, list) and bool(messages) and isinstance(messages[0], list)
def _as_token_ids(value: Any) -> list[int]:
if isinstance(value, dict) or (hasattr(value, "keys") and "input_ids" in value.keys()):
value = value["input_ids"]
if hasattr(value, "tolist"):
value = value.tolist()
if isinstance(value, list) and value and isinstance(value[0], list):
value = value[0]
return [int(i) for i in value]
# Re-export for tests / introspection
strip_lerobot_blocks = _strip_lerobot_blocks
@@ -40,6 +40,7 @@ from .steps import (
UserInterjectionFwd,
)
from .triggers import EventTrigger, HzTrigger, Tick, TickClock, Trigger
from .ui import make_state_panel, print_robot_lines, print_user_line
__all__ = [
# runtime
@@ -65,4 +66,8 @@ __all__ = [
"UserInterjectionFwd",
"AskVQAFwd",
"DispatchToolCalls",
# UI
"make_state_panel",
"print_robot_lines",
"print_user_line",
]
@@ -82,10 +82,20 @@ class SmolVLA2Runtime:
HighLevelSubtaskFwd(
trigger=HzTrigger(self.high_level_hz),
policy=self.policy,
observation_provider=self.observation_provider,
),
MemoryUpdateFwd(
policy=self.policy,
observation_provider=self.observation_provider,
),
UserInterjectionFwd(
policy=self.policy,
observation_provider=self.observation_provider,
),
AskVQAFwd(
policy=self.policy,
observation_provider=self.observation_provider,
),
MemoryUpdateFwd(policy=self.policy),
UserInterjectionFwd(policy=self.policy),
AskVQAFwd(policy=self.policy),
DispatchToolCalls(tools=self.tools),
]
self.state = initial_runtime_state()
@@ -127,6 +137,39 @@ class SmolVLA2Runtime:
self._on_shutdown()
# ------------------------------------------------------------------
# REPL helper: drive one full pipeline pass and return its logs
# ------------------------------------------------------------------
def step_once(self) -> list[str]:
"""Run one tick of the pipeline and return the log lines.
Used by the interactive REPL: instead of a background thread,
the CLI drives ticks synchronously after each user input. Logs
are returned (not printed) so the caller can route them into
the rich-Live chat scrollback.
"""
from .triggers import Tick # noqa: PLC0415
# Synthesize a tick. We don't need the real wall-clock pacing
# here — the REPL drives the runtime, not vice versa — but
# ``HzTrigger`` uses ``tick.monotonic_seconds`` to gate, so we
# bump it generously so every Hz-triggered step considers
# itself due.
import time as _time # noqa: PLC0415
prev_index = self.state.get("_tick").index if isinstance(self.state.get("_tick"), Tick) else 0
self.state["_tick"] = Tick(index=prev_index + 1, monotonic_seconds=_time.monotonic())
self.state["log_lines"] = []
# ``events_this_tick`` is set up by the caller before
# ``step_once`` (the REPL pushes user-driven events first).
self.state.setdefault("events_this_tick", [])
for step in self.pipeline:
self.state = step(self.state)
return list(self.state.get("log_lines") or [])
# ------------------------------------------------------------------
# I/O
# ------------------------------------------------------------------
+265 -34
View File
@@ -91,10 +91,36 @@ class LowLevelForward(InferenceStep):
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
if self.policy is None or self.observation_provider is None:
return None
if not state.get("task"):
# No task yet → nothing useful to condition on.
return None
observation = self.observation_provider()
if observation is None:
return None
action = self.policy.select_action(observation)
# SmolVLA's ``select_action`` expects the full preprocessed
# batch, including ``OBS_LANGUAGE_TOKENS`` /
# ``OBS_LANGUAGE_ATTENTION_MASK``. The observation provider
# only returns image / state features (the runtime drives
# messages itself), so build a low-level prompt from current
# runtime state and tokenize it inline.
ctx = _control_context_messages(state)
if state.get("current_subtask"):
ctx = ctx + [{"role": "assistant", "content": state["current_subtask"]}]
text_batch = _build_text_batch(self.policy, ctx)
from lerobot.utils.constants import ( # noqa: PLC0415
OBS_LANGUAGE_ATTENTION_MASK,
OBS_LANGUAGE_TOKENS,
)
observation = dict(observation)
observation[OBS_LANGUAGE_TOKENS] = text_batch["lang_tokens"]
observation[OBS_LANGUAGE_ATTENTION_MASK] = text_batch["lang_masks"]
try:
action = self.policy.select_action(observation)
except Exception as exc: # noqa: BLE001
logger.warning("select_action failed: %s", exc, exc_info=logger.isEnabledFor(logging.DEBUG))
push_log(state, f" [warn] select_action failed: {type(exc).__name__}: {exc}")
return None
# SmolVLA returns a single action; if the underlying policy
# streams chunks, split per-step here. For v1 we just enqueue
# the result.
@@ -145,17 +171,68 @@ def _build_text_batch(policy: Any, prompt_messages: list[dict[str, Any]]) -> dic
tokenizer.pad_token = tokenizer.eos_token
text_messages = [_strip_recipe_keys(m) for m in prompt_messages]
ids = tokenizer.apply_chat_template(
encoded = tokenizer.apply_chat_template(
text_messages,
add_generation_prompt=True,
tokenize=True,
return_tensors="pt",
)
# ``apply_chat_template`` can return any of:
# - a Tensor of shape ``(seq,)`` or ``(1, seq)`` (older transformers),
# - a list[int] / list[list[int]] (when ``return_tensors`` is ignored),
# - a ``BatchEncoding`` dict-like with ``input_ids`` / ``attention_mask``
# (newer transformers, especially via processor.apply_chat_template
# forwarding through here).
# Normalise to ``ids: Tensor[1, seq]`` and grab the encoder's
# attention mask when available so we don't have to re-derive it
# from ``pad_token_id`` (which can be ``None`` for SmolVLM).
attn: Any = None
if hasattr(encoded, "input_ids"):
ids = encoded.input_ids
attn = getattr(encoded, "attention_mask", None)
elif isinstance(encoded, dict) and "input_ids" in encoded:
ids = encoded["input_ids"]
attn = encoded.get("attention_mask")
else:
ids = encoded
if isinstance(ids, list):
ids = ids[0] if ids else []
if ids and isinstance(ids[0], list):
ids = ids[0]
import torch # noqa: PLC0415
ids = torch.tensor(ids, dtype=torch.long)
if hasattr(ids, "ndim") and ids.ndim == 1:
ids = ids.unsqueeze(0)
attn = (ids != tokenizer.pad_token_id) if tokenizer.pad_token_id is not None else None
if attn is None and tokenizer.pad_token_id is not None:
attn = ids != tokenizer.pad_token_id
elif isinstance(attn, list):
import torch # noqa: PLC0415
attn = torch.tensor(attn, dtype=torch.long)
if attn.ndim == 1:
attn = attn.unsqueeze(0)
# SmolVLA's ``eager_attention_forward`` does
# ``torch.where(attention_mask[..., None, :, :], ...)`` which
# requires a *bool* condition tensor; ``BatchEncoding``'s
# attention_mask is typically Long (0/1). Cast so the prefix
# forward doesn't blow up with ``where expected condition to be a
# boolean tensor, but got a tensor with dtype Long``.
if attn is not None and hasattr(attn, "dtype"):
import torch as _torch # noqa: PLC0415
if attn.dtype != _torch.bool:
attn = attn.bool()
# Move tokens onto the policy's device — otherwise prefix embedding
# raises a device-mismatch on every forward (CPU tensor vs MPS / CUDA
# model), which the caller's broad except would swallow silently.
device = getattr(getattr(policy, "config", None), "device", None)
if device is not None:
try:
ids = ids.to(device)
if attn is not None and hasattr(attn, "to"):
attn = attn.to(device)
except Exception as exc: # noqa: BLE001
logger.debug("could not move lang tokens to %s: %s", device, exc)
return {"lang_tokens": ids, "lang_masks": attn, "tokenizer": tokenizer}
@@ -168,37 +245,68 @@ def _strip_recipe_keys(m: dict[str, Any]) -> dict[str, Any]:
@dataclass
class HighLevelSubtaskFwd(InferenceStep):
"""At ~1 Hz, ask the policy for the next subtask."""
"""At ~1 Hz, ask the policy for the next subtask.
Mirrors the ``high_level_subtask`` recipe layout exactly:
user: "${task}\\nPlan: ${plan}\\nMemory: ${memory}"
user: "Current subtask: ${subtask}" (if subtask present)
generate
assistant: <next subtask>
"""
policy: Any = None
observation_provider: Any = None
"""Same shape as ``LowLevelForward.observation_provider``. When
set, the resulting observation is merged into ``select_message``'s
batch so text generation runs against real video + state."""
trigger: Trigger = field(default_factory=lambda: HzTrigger(hz=1.0))
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
if self.policy is None or not state.get("task"):
return None
ctx = _control_context_messages(state)
msg = _generate_with_policy(self.policy, ctx)
ctx = _msgs_for_subtask(state)
observation = _maybe_observation(self.observation_provider)
msg = _generate_with_policy(
self.policy, ctx, observation=observation, state=state, label="subtask gen"
)
if msg:
changed = set_if_changed(state, "current_subtask", msg, label="subtask")
if changed:
# Subtask change is a downstream trigger.
state.setdefault("events_this_tick", []).append("subtask_change")
else:
push_log(state, " [info] subtask gen produced no text this tick")
return None
@dataclass
class MemoryUpdateFwd(InferenceStep):
"""On subtask boundary, refresh the compressed memory."""
"""On subtask boundary, refresh the compressed memory.
Mirrors the ``memory_update`` recipe layout exactly:
user: "${task}"
assistant: "Previous memory: ${prior_memory}" (if prior memory)
user: "Completed subtask: ${completed_subtask}" (if subtask)
generate
assistant: <new memory>
"""
policy: Any = None
observation_provider: Any = None
trigger: Trigger = field(default_factory=lambda: EventTrigger("subtask_change"))
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
# Don't consume the event — multiple steps may want to react.
if self.policy is None:
return None
ctx = _control_context_messages(state, include_completed=True)
new_memory = _generate_with_policy(self.policy, ctx)
ctx = _msgs_for_memory(state)
observation = _maybe_observation(self.observation_provider)
new_memory = _generate_with_policy(
self.policy, ctx, observation=observation, state=state, label="memory gen"
)
if new_memory:
set_if_changed(state, "current_memory", new_memory, label="memory")
return None
@@ -206,20 +314,31 @@ class MemoryUpdateFwd(InferenceStep):
@dataclass
class UserInterjectionFwd(InferenceStep):
"""On stdin interjection, refresh the plan + emit a paired ``say``."""
"""On stdin interjection, refresh the plan + emit a paired ``say``.
Mirrors the ``user_interjection_response`` recipe layout exactly:
user: "${task}"
assistant: "Previous plan:\\n${prior_plan}" (if prior plan)
user: "${interjection}" (the new utterance)
generate
assistant: <plan + <say>...</say>>
"""
policy: Any = None
observation_provider: Any = None
trigger: Trigger = field(default_factory=lambda: EventTrigger("user_interjection"))
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
if self.policy is None or not take_event(state, "user_interjection"):
return None
ctx = _control_context_messages(
state,
extra_user=state.get("recent_interjection"),
ctx = _msgs_for_interjection(state)
observation = _maybe_observation(self.observation_provider)
out = _generate_with_policy(
self.policy, ctx, observation=observation, state=state, label="plan/say gen"
)
out = _generate_with_policy(self.policy, ctx)
if not out:
push_log(state, " [info] plan/say gen produced no text this tick")
return None
# Heuristic split: model is trained to emit one assistant turn
# carrying both plan text AND a `say` tool call. Look for a
@@ -244,9 +363,20 @@ class UserInterjectionFwd(InferenceStep):
@dataclass
class AskVQAFwd(InferenceStep):
"""On stdin question, answer a frame-grounded VQA."""
"""On stdin question, answer a frame-grounded VQA.
Mirrors the ``ask_vqa_*`` recipe layout exactly: a single user
turn carrying just the VQA question, plus the camera image block
in training (we drop the image at inference because the dataset's
image preprocessing doesn't match SmolVLM's vision tower input).
user: <question>
generate
assistant: <vqa answer>
"""
policy: Any = None
observation_provider: Any = None
trigger: Trigger = field(default_factory=lambda: EventTrigger("user_vqa_query"))
def run(self, state: dict[str, Any]) -> dict[str, Any] | None:
@@ -255,8 +385,11 @@ class AskVQAFwd(InferenceStep):
question = state.get("recent_vqa_query")
if not question:
return None
ctx = _control_context_messages(state, extra_user=question)
answer = _generate_with_policy(self.policy, ctx)
ctx = _msgs_for_vqa(question)
observation = _maybe_observation(self.observation_provider)
answer = _generate_with_policy(
self.policy, ctx, observation=observation, state=state, label="vqa gen"
)
if answer:
push_log(state, f" vqa: {answer}")
state["recent_vqa_query"] = None
@@ -326,38 +459,136 @@ def _control_context_messages(
return msgs
def _generate_with_policy(policy: Any, messages: list[dict[str, Any]]) -> str:
"""Drive ``policy.select_message`` with a minimal text-only batch.
# ---------------------------------------------------------------------------
# Per-recipe prompt builders. Each one mirrors a single sub-recipe's
# message layout in ``smolvla2_hirobot.yaml`` so the chat-templated
# prompt at inference matches what the model saw during training.
# Generic ``_control_context_messages`` is kept around as a fallback
# for ad-hoc callers but the four high-level steps now use these.
# ---------------------------------------------------------------------------
Best-effort: the runtime today doesn't construct a full
observation batch with images / state for text generation; the
text-head was trained over images + lang + state, so generations
here may differ in distribution from training. This is acceptable
for a v1 REPL; a follow-up will plug in the real observation.
def _msgs_for_subtask(state: dict[str, Any]) -> list[dict[str, Any]]:
"""``high_level_subtask`` recipe layout."""
head_parts = [state.get("task") or ""]
if state.get("current_plan"):
head_parts.append(f"Plan: {state['current_plan']}")
if state.get("current_memory"):
head_parts.append(f"Memory: {state['current_memory']}")
msgs: list[dict[str, Any]] = [
{"role": "user", "content": "\n".join(head_parts)}
]
if state.get("current_subtask"):
msgs.append(
{"role": "user", "content": f"Current subtask: {state['current_subtask']}"}
)
return msgs
def _msgs_for_memory(state: dict[str, Any]) -> list[dict[str, Any]]:
"""``memory_update`` recipe layout."""
msgs: list[dict[str, Any]] = [
{"role": "user", "content": state.get("task") or ""}
]
if state.get("current_memory"):
msgs.append(
{
"role": "assistant",
"content": f"Previous memory: {state['current_memory']}",
}
)
if state.get("current_subtask"):
msgs.append(
{
"role": "user",
"content": f"Completed subtask: {state['current_subtask']}",
}
)
return msgs
def _msgs_for_interjection(state: dict[str, Any]) -> list[dict[str, Any]]:
"""``user_interjection_response`` recipe layout."""
msgs: list[dict[str, Any]] = [
{"role": "user", "content": state.get("task") or ""}
]
if state.get("current_plan"):
msgs.append(
{"role": "assistant", "content": f"Previous plan:\n{state['current_plan']}"}
)
interjection = state.get("recent_interjection")
if interjection:
msgs.append({"role": "user", "content": interjection})
return msgs
def _msgs_for_vqa(question: str) -> list[dict[str, Any]]:
"""``ask_vqa_*`` recipe layout (text-only at inference)."""
return [{"role": "user", "content": question}]
def _maybe_observation(provider: Any) -> dict | None:
"""Pull one observation from ``provider`` if it's set, else ``None``.
Errors from the provider are logged at debug level and swallowed
text generation still runs (in text-only mode) so a flaky frame
source doesn't kill the REPL.
"""
if provider is None:
return None
try:
return provider()
except Exception as exc: # noqa: BLE001
logger.debug("observation_provider raised %s — falling back to text-only", exc)
return None
def _generate_with_policy(
policy: Any,
messages: list[dict[str, Any]],
*,
observation: dict | None = None,
state: dict[str, Any] | None = None,
label: str = "select_message",
) -> str:
"""Drive ``policy.select_message`` with a chat batch (and optional obs).
When ``observation`` carries ``observation.images.*`` and
``observation.state``, those are merged into the batch so
``select_message`` runs the same VLM prefix the policy was trained
on. Without an observation the runtime falls back to a text-only
prompt the text head still runs, but generations may drift from
the training distribution.
Failures are surfaced both to the module logger (``warning``) and,
when ``state`` is given, to the runtime's user-visible log via
:func:`push_log`, so the REPL no longer "looks dead" when
something goes wrong inside generation.
"""
if not hasattr(policy, "select_message"):
if state is not None:
push_log(state, f" [warn] policy has no select_message — skipping {label}")
return ""
text_batch = _build_text_batch(policy, messages)
# ``select_message`` expects a real batch with OBS_LANGUAGE_TOKENS.
# The minimal text-only batch we build doesn't have images / state,
# so we either run a text-only forward (handled by SmolVLA2 when
# supported) or skip and return empty. v1 returns empty when the
# policy can't handle it; the runtime logs and continues.
try:
# Convert to the OBS_LANGUAGE_TOKENS / OBS_LANGUAGE_ATTENTION_MASK
# keys ``select_message`` uses internally.
from lerobot.utils.constants import ( # noqa: PLC0415
OBS_LANGUAGE_ATTENTION_MASK,
OBS_LANGUAGE_TOKENS,
)
batch = {
batch: dict[str, Any] = {
OBS_LANGUAGE_TOKENS: text_batch["lang_tokens"],
OBS_LANGUAGE_ATTENTION_MASK: text_batch["lang_masks"],
}
if observation:
for k, v in observation.items():
if isinstance(k, str) and k.startswith("observation.") and k not in batch:
batch[k] = v
return policy.select_message(batch, tokenizer=text_batch["tokenizer"])
except Exception as exc: # noqa: BLE001
logger.debug("select_message fell back: %s", exc)
logger.warning("%s failed: %s", label, exc, exc_info=logger.isEnabledFor(logging.DEBUG))
if state is not None:
push_log(state, f" [warn] {label} failed: {type(exc).__name__}: {exc}")
return ""
@@ -0,0 +1,119 @@
# 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.
"""Rich-based REPL layout for the SmolVLA2 runtime.
Two-zone terminal layout:
[chat scrollback user messages / robot responses, scrolls naturally]
State
task please clean up the kitchen
subtask grasp the handle of the sponge
plan 1. grasp sponge 2. wipe 3. tidy
memory sponge picked up; counter still dirty
> _
The state panel re-renders on every state change. Chat lines are
``console.print``'d above the live region so they accumulate naturally
in scrollback. Implemented with :class:`rich.live.Live` plus
:func:`rich.console.Console.input` for the prompt when an input is
pending, ``rich.Live`` auto-suspends so the input doesn't fight the
panel for cursor position.
"""
from __future__ import annotations
from typing import Any
try: # rich is optional; only required for the interactive REPL.
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
_HAS_RICH = True
except ImportError: # pragma: no cover
_HAS_RICH = False
Console = Any # type: ignore[assignment]
Panel = Any # type: ignore[assignment]
Table = Any # type: ignore[assignment]
Text = Any # type: ignore[assignment]
_STATE_KEYS = (
("task", "task"),
("current_subtask", "subtask"),
("current_plan", "plan"),
("current_memory", "memory"),
)
def make_state_panel(state: dict[str, Any]) -> Any:
"""Render the persistent state panel for the live region.
Returns a :class:`rich.panel.Panel`. Caller passes it to
``Live.update(panel)`` whenever the state changes.
"""
if not _HAS_RICH:
raise RuntimeError(
"rich is required for the interactive REPL. "
"`pip install rich` (it's a transitive dep of lerobot)."
)
table = Table.grid(padding=(0, 2), expand=True)
table.add_column(justify="right", style="dim", no_wrap=True, width=10)
table.add_column(justify="left")
for key, label in _STATE_KEYS:
value = state.get(key)
if value is None:
rendered = Text("(not set)", style="dim italic")
else:
rendered = Text(str(value), style="bold")
table.add_row(label, rendered)
queue = state.get("action_queue")
queue_len = len(queue) if hasattr(queue, "__len__") else 0
pending = state.get("tool_calls_pending") or []
footer = Text.assemble(
("queued actions: ", "dim"),
(str(queue_len), "bold cyan"),
(" pending tool calls: ", "dim"),
(str(len(pending)), "bold magenta"),
)
table.add_row("", footer)
return Panel(table, title="[bold]SmolVLA2 state[/]", border_style="cyan")
def print_user_line(console: Any, line: str) -> None:
"""Append a user-typed line to the chat scrollback."""
if not _HAS_RICH:
print(f"you: {line}", flush=True)
return
console.print(f"[bold cyan]you:[/] {line}")
def print_robot_lines(console: Any, lines: list[str]) -> None:
"""Append robot/runtime log lines to the chat scrollback."""
if not _HAS_RICH:
for line in lines:
print(f"robot: {line.lstrip()}", flush=True)
return
for line in lines:
# The runtime uses leading whitespace + "label: text"; render
# the label in green and the value in default for readability.
stripped = line.lstrip()
if ":" in stripped:
label, _, value = stripped.partition(":")
console.print(f"[bold green]robot[/] [dim]({label.strip()})[/] {value.strip()}")
else:
console.print(f"[bold green]robot:[/] {stripped}")
@@ -60,7 +60,7 @@ class SmolVLA2Policy(SmolVLAPolicy):
config_class = SmolVLA2Config
name = "smolvla2"
def __init__(self, config: SmolVLA2Config, dataset_stats: dict[str, dict[str, Tensor]] | None = None):
def __init__(self, config: SmolVLA2Config, **kwargs):
if not isinstance(config, SmolVLA2Config):
config = SmolVLA2Config(
**{
@@ -69,7 +69,7 @@ class SmolVLA2Policy(SmolVLAPolicy):
if hasattr(config, f.name)
}
)
super().__init__(config, dataset_stats=dataset_stats)
super().__init__(config, **kwargs)
if config.unfreeze_lm_head and config.text_loss_weight > 0:
self._unfreeze_lm_head()
@@ -200,7 +200,7 @@ class SmolVLA2Policy(SmolVLAPolicy):
past_key_values=None,
inputs_embeds=[prefix_embs, None],
use_cache=False,
fill_kv_cache=False,
fill_kv_cache=True,
)
prefix_out = out_pair[0] if isinstance(out_pair, (tuple, list)) else out_pair
if prefix_out is None:
@@ -228,8 +228,8 @@ class SmolVLA2Policy(SmolVLAPolicy):
f"num_state={num_state})."
)
lang_hidden = prefix_out[:, lang_start:lang_end]
vlm = self.model.vlm_with_expert.vlm
lang_hidden = prefix_out[:, lang_start:lang_end].to(vlm.lm_head.weight.dtype)
logits = vlm.lm_head(lang_hidden) # (B, num_lang, vocab)
if text_labels.shape[1] != num_lang:
@@ -244,12 +244,14 @@ class SmolVLA2Policy(SmolVLAPolicy):
# for the same convention.
shift_logits = logits[:, :-1, :].contiguous()
shift_labels = text_labels[:, 1:].contiguous().long()
valid_labels = shift_labels != -100
loss = F.cross_entropy(
shift_logits.reshape(-1, shift_logits.shape[-1]),
shift_labels.reshape(-1),
ignore_index=-100,
reduction="sum",
)
return loss
return loss / valid_labels.sum().clamp(min=1)
# ------------------------------------------------------------------
# Inference: text generation
@@ -301,40 +303,67 @@ class SmolVLA2Policy(SmolVLAPolicy):
if eos_token_id is None:
eos_token_id = tokenizer.eos_token_id
# Match training's text-loss forward path (see
# ``_compute_text_loss`` above): build the full prefix via
# ``embed_prefix`` so images + state conditioning is intact,
# then loop AR with ``fill_kv_cache=True, use_cache=False``.
# That flag combo routes every layer through
# ``forward_attn_layer`` (which gracefully skips ``None``
# expert inputs via ``if hidden_states is None or layer is
# None: continue``) and short-circuits the cache-update logic
# so we don't have to manage past_kv. Each step just
# re-forwards the cumulative ``[prefix + generated]``
# sequence.
#
# This is O(n²) in generated text length but cheap in
# absolute terms: image encoding happens once via the initial
# ``embed_prefix`` call, and the per-step cost is just one
# SmolVLM transformer pass over a sequence that grows by one
# token each time. Standard SmolVLM ``generate`` was the
# other tempting path, but it can't accept SmolVLA's custom
# ``state_proj`` output and its tile-grid expectations
# disagree with our preprocessor — both lead to garbage
# generation, which is what the prior approach produced.
images, img_masks = self.prepare_images(batch)
state = self.prepare_state(batch)
lang_tokens = batch[OBS_LANGUAGE_TOKENS]
lang_masks = batch[OBS_LANGUAGE_ATTENTION_MASK]
# 1) Embed prefix (images + lang + state) and run with KV cache.
prefix_embs, prefix_pad_masks, prefix_att_masks = self.model.embed_prefix(
images, img_masks, lang_tokens, lang_masks, state=state
)
prefix_2d = make_att_2d_masks(prefix_pad_masks, prefix_att_masks)
prefix_pos = torch.cumsum(prefix_pad_masks, dim=1) - 1
out_pair, past_kv = self.model.vlm_with_expert.forward(
attention_mask=prefix_2d,
position_ids=prefix_pos,
past_key_values=None,
inputs_embeds=[prefix_embs, None],
use_cache=True,
fill_kv_cache=True,
)
prefix_out = out_pair[0] if isinstance(out_pair, (tuple, list)) else out_pair
if prefix_out is None:
raise RuntimeError("select_message: prefix forward returned no hidden states.")
vlm = self.model.vlm_with_expert.vlm
# 2) Initial logits — sample first new token from the last
# prefix position.
last_hidden = prefix_out[:, -1:]
device = last_hidden.device
device = prefix_embs.device
bsize = prefix_embs.shape[0]
cur_pos = int(prefix_embs.shape[1])
vlm = self.model.vlm_with_expert.vlm
emb_dim = prefix_embs.shape[-1]
text_emb_scale = math.sqrt(emb_dim)
current_embs = prefix_embs
current_pad = prefix_pad_masks
current_att = prefix_att_masks
ones_step = torch.ones((bsize, 1), dtype=torch.bool, device=device)
generated: list[int] = []
for _ in range(max_new_tokens):
full_2d = make_att_2d_masks(current_pad, current_att)
full_pos = torch.cumsum(current_pad, dim=1) - 1
out_pair, _ = self.model.vlm_with_expert.forward(
attention_mask=full_2d,
position_ids=full_pos,
past_key_values=None,
inputs_embeds=[current_embs, None],
use_cache=False,
fill_kv_cache=True,
)
prefix_out = out_pair[0] if isinstance(out_pair, (tuple, list)) else out_pair
if prefix_out is None:
raise RuntimeError(
"select_message: vlm_with_expert.forward returned no hidden states."
)
last_hidden = prefix_out[:, -1:].to(vlm.lm_head.weight.dtype)
logits_step = vlm.lm_head(last_hidden)[:, -1] # (B, V)
next_ids = self._sample_next_token(logits_step, temperature, top_p)
tok_id = int(next_ids[0].item())
@@ -342,26 +371,13 @@ class SmolVLA2Policy(SmolVLAPolicy):
if eos_token_id is not None and tok_id == eos_token_id:
break
# 3) Embed the new token and forward with KV cache.
new_emb = self.model.vlm_with_expert.embed_language_tokens(
next_ids.unsqueeze(0)
)
new_emb = new_emb * math.sqrt(new_emb.shape[-1])
new_pos = torch.full((bsize, 1), cur_pos, device=device, dtype=torch.long)
new_attn = torch.ones((bsize, cur_pos + 1), device=device, dtype=torch.bool)
out_pair, past_kv = self.model.vlm_with_expert.forward(
attention_mask=new_attn,
position_ids=new_pos,
past_key_values=past_kv,
inputs_embeds=[new_emb, None],
use_cache=True,
fill_kv_cache=True,
)
new_prefix_out = out_pair[0] if isinstance(out_pair, (tuple, list)) else out_pair
last_hidden = new_prefix_out[:, -1:]
cur_pos += 1
new_emb = new_emb * text_emb_scale
current_embs = torch.cat([current_embs, new_emb], dim=1)
current_pad = torch.cat([current_pad, ones_step], dim=1)
current_att = torch.cat([current_att, ones_step], dim=1)
return tokenizer.decode(generated, skip_special_tokens=True).strip()
-3
View File
@@ -175,9 +175,6 @@ class AddBatchDimensionComplementaryDataStep(ComplementaryDataProcessorStep):
if isinstance(task_index_value, Tensor) and task_index_value.dim() == 0:
complementary_data["task_index"] = task_index_value.unsqueeze(0)
complementary_data.pop("language_persistent", None)
complementary_data.pop("language_events", None)
if "messages" in complementary_data:
messages = complementary_data["messages"]
if isinstance(messages, list) and (not messages or isinstance(messages[0], dict)):
@@ -51,6 +51,9 @@ class RenderMessagesStep(ProcessorStep):
if not persistent and not events:
return transition
if _is_batched_language(persistent) or _is_batched_language(events):
return self._call_batch(transition, complementary_data, persistent, events)
timestamp = complementary_data.get("timestamp")
if timestamp is None:
raise KeyError("RenderMessagesStep requires sample timestamp in complementary data.")
@@ -69,13 +72,64 @@ class RenderMessagesStep(ProcessorStep):
return None
new_transition = transition.copy()
new_complementary_data = dict(complementary_data)
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data.update(rendered)
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
def _call_batch(
self,
transition: EnvTransition,
complementary_data: dict[str, Any],
persistent_batch: list,
events_batch: list,
) -> EnvTransition | None:
timestamp = complementary_data.get("timestamp")
if timestamp is None:
raise KeyError("RenderMessagesStep requires sample timestamp in complementary data.")
batch_size = max(len(persistent_batch), len(events_batch))
messages: list[list[dict[str, Any]]] = []
message_streams: list[list[str | None]] = []
target_message_indices: list[list[int]] = []
keep_indices: list[int] = []
for i in range(batch_size):
rendered = render_sample(
recipe=self.recipe,
persistent=persistent_batch[i] if i < len(persistent_batch) else [],
events=events_batch[i] if i < len(events_batch) else [],
t=_batch_value(timestamp, i),
sample_idx=int(_batch_value(complementary_data.get("index", 0), i)),
task=_batch_value(complementary_data.get("task"), i),
dataset_ctx=self.dataset_ctx,
)
if rendered is None:
continue
keep_indices.append(i)
messages.append(rendered["messages"])
message_streams.append(rendered["message_streams"])
target_message_indices.append(rendered["target_message_indices"])
if not messages:
return None
new_transition = (
_select_batch_indices(transition, keep_indices)
if len(keep_indices) != batch_size
else transition.copy()
)
new_complementary_data = dict(new_transition.get(TransitionKey.COMPLEMENTARY_DATA) or {})
new_complementary_data.pop(LANGUAGE_PERSISTENT, None)
new_complementary_data.pop(LANGUAGE_EVENTS, None)
new_complementary_data["messages"] = messages
new_complementary_data["message_streams"] = message_streams
new_complementary_data["target_message_indices"] = target_message_indices
new_transition[TransitionKey.COMPLEMENTARY_DATA] = new_complementary_data
return new_transition
def transform_features(
self, features: dict[PipelineFeatureType, dict[str, PolicyFeature]]
) -> dict[PipelineFeatureType, dict[str, PolicyFeature]]:
@@ -90,3 +144,37 @@ def _scalar(value: Any) -> float | int:
if isinstance(value, list) and len(value) == 1:
return _scalar(value[0])
return value
def _is_batched_language(value: Any) -> bool:
return isinstance(value, list) and bool(value) and isinstance(value[0], list)
def _batch_value(value: Any, index: int) -> Any:
if value is None:
return None
if isinstance(value, list):
return value[index]
if hasattr(value, "ndim") and getattr(value, "ndim") > 0:
return _scalar(value[index])
return _scalar(value)
def _select_batch_indices(transition: EnvTransition, indices: list[int]) -> EnvTransition:
selected = transition.copy()
for key in (TransitionKey.OBSERVATION, TransitionKey.COMPLEMENTARY_DATA):
data = selected.get(key)
if isinstance(data, dict):
selected[key] = {k: _select_value(v, indices) for k, v in data.items()}
action = selected.get(TransitionKey.ACTION)
if action is not None:
selected[TransitionKey.ACTION] = _select_value(action, indices)
return selected
def _select_value(value: Any, indices: list[int]) -> Any:
if isinstance(value, list) and len(value) >= len(indices):
return [value[i] for i in indices]
if hasattr(value, "index_select") and hasattr(value, "new_tensor") and getattr(value, "ndim", 0) > 0:
return value.index_select(0, value.new_tensor(indices).long())
return value
+366 -34
View File
@@ -23,11 +23,21 @@ speech) as they happen.
Examples
--------
Dry run on a checkpoint, no robot connected useful for sanity-
Dry run on a Hub checkpoint, no robot connected useful for sanity-
checking text generation::
uv run lerobot-smolvla2-runtime \\
--policy.path=outputs/train/smolvla2_super_poulain/000020000/pretrained_model \\
--policy.path=pepijn223/smolvla2_hirobot_super_poulain_tool2 \\
--no_robot \\
--task="please clean the kitchen"
Same, but feed real frames from an annotated dataset so plan / subtask
/ memory / VQA generation runs against actual video + state::
uv run lerobot-smolvla2-runtime \\
--policy.path=pepijn223/smolvla2_hirobot_super_poulain_tool2 \\
--dataset.repo_id=pepijn223/super_poulain_annotated \\
--dataset.episode=0 \\
--no_robot \\
--task="please clean the kitchen"
@@ -38,6 +48,9 @@ With a real robot::
--robot.type=so101 --robot.port=/dev/tty.usbmodem... \\
--tts.voice=alba
``--policy.path`` accepts either a local directory or a Hugging Face
Hub repo id. ``--dataset.repo_id`` likewise.
Tool dispatch (TTS via ``SayTool``) is enabled by default when
``pocket-tts`` is installed; pass ``--no_tts`` to disable.
"""
@@ -47,8 +60,7 @@ from __future__ import annotations
import argparse
import logging
import sys
from pathlib import Path
from typing import Any
from typing import Any, Callable
logger = logging.getLogger("lerobot.smolvla2.runtime")
@@ -61,9 +73,51 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
p.add_argument(
"--policy.path",
dest="policy_path",
type=Path,
type=str,
required=True,
help="Path to a trained SmolVLA2 ``pretrained_model`` directory.",
help=(
"Local directory or Hugging Face Hub repo id pointing at a "
"trained SmolVLA2 ``pretrained_model``."
),
)
p.add_argument(
"--dataset.repo_id",
dest="dataset_repo_id",
type=str,
default=None,
help=(
"Optional dataset (local path or Hub repo id) used to drive "
"observations during dry-run inference. When set, the runtime "
"reads camera frames + state from the chosen episode and feeds "
"them into all forward passes — so plan / subtask / memory / "
"VQA generation see the same visual context the policy was "
"trained on."
),
)
p.add_argument(
"--dataset.episode",
dest="dataset_episode",
type=int,
default=0,
help="Episode index to walk through (default: 0).",
)
p.add_argument(
"--dataset.start_frame",
dest="dataset_start_frame",
type=int,
default=0,
help="Frame index within the episode to start from (default: 0).",
)
p.add_argument(
"--dataset.advance_per_tick",
dest="dataset_advance_per_tick",
type=int,
default=1,
help=(
"How many dataset frames to advance per runtime tick. The "
"default of 1 means the runtime walks the episode forward "
"frame by frame; set to 0 to freeze on ``start_frame``."
),
)
p.add_argument(
"--task",
@@ -111,16 +165,149 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
return p.parse_args(argv)
def _load_policy(path: Path): # noqa: ANN202
"""Load a SmolVLA2 checkpoint from ``path``."""
from lerobot.policies.factory import make_policy_from_path # noqa: PLC0415
def _load_policy_and_preprocessor(
policy_path: str,
dataset_repo_id: str | None,
) -> tuple[Any, Any, Any]:
"""Load a SmolVLA2 checkpoint (local path or Hub repo id).
When ``dataset_repo_id`` is provided, the dataset's metadata is used
to derive policy features (matching the standard
``make_policy(cfg, ds_meta=...)`` flow used by ``lerobot-train`` and
``lerobot-record``). When it isn't, we fall back to instantiating
the policy directly via ``from_pretrained`` this skips the
feature-derivation path that ``make_policy`` insists on, but also
means we can't load the saved preprocessor pipeline (which depends
on ``input_features`` / ``output_features``). For inference-only
dry-runs this is fine; the policy still loads.
Returns ``(policy, preprocessor, ds_meta)`` where ``preprocessor``
and ``ds_meta`` may be ``None`` if no dataset was provided.
"""
from lerobot.configs import PreTrainedConfig # noqa: PLC0415
from lerobot.policies.factory import make_policy, make_pre_post_processors # noqa: PLC0415
cfg = PreTrainedConfig.from_pretrained(policy_path)
cfg.pretrained_path = policy_path
ds_meta = None
preprocessor = None
if dataset_repo_id is not None:
from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata # noqa: PLC0415
ds_meta = LeRobotDatasetMetadata(dataset_repo_id)
policy = make_policy(cfg, ds_meta=ds_meta)
# NOTE: we deliberately pass ``pretrained_path=None`` here even
# though the checkpoint ships a ``policy_preprocessor.json``.
# ``RenderMessagesStep`` carries a ``TrainingRecipe`` field that
# isn't faithfully serialized into that JSON, so the saved
# pipeline can't currently be round-tripped via
# ``PolicyProcessorPipeline.from_pretrained`` — it crashes with
# ``RenderMessagesStep.__init__() missing 1 required argument:
# 'recipe'``. Building fresh from ``cfg`` re-runs
# ``make_smolvla2_pre_post_processors``, which loads the recipe
# YAML referenced by ``cfg.recipe_path`` and wires it back into
# ``RenderMessagesStep`` correctly. Normalization stats come
# from ``ds_meta.stats`` (the same dataset the user is feeding
# into the runtime), so no quality loss in practice.
preprocessor, _ = make_pre_post_processors(
cfg,
pretrained_path=None,
dataset_stats=ds_meta.stats,
)
else:
# No dataset: instantiate the policy class directly so we don't
# need ds_meta. This bypasses ``make_policy``'s feature-shape
# derivation, which is fine for a pretrained checkpoint where
# the saved config already carries those shapes.
from lerobot.policies.factory import get_policy_class # noqa: PLC0415
policy_cls = get_policy_class(cfg.type)
policy = policy_cls.from_pretrained(policy_path, config=cfg)
policy.to(cfg.device)
policy = make_policy_from_path(str(path))
policy.eval()
return policy
return policy, preprocessor, ds_meta
def _build_tools(policy_path: Path, no_tts: bool, tts_voice: str) -> dict[str, Any]:
def _build_observation_provider(
*,
dataset_repo_id: str,
episode: int,
start_frame: int,
advance_per_tick: int,
preprocessor: Any,
device: str,
) -> Callable[[], dict | None]:
"""Build a closure that feeds dataset frames into the runtime.
Each call returns a preprocessed observation batch (images +
state, batched, on the policy's device, normalized) suitable for
``policy.select_action`` and ``policy.select_message``. The
closure walks the chosen episode forward by ``advance_per_tick``
frames per call, looping back to the episode start when it falls
off the end.
The dataset's ``language_persistent`` / ``language_events``
columns are stripped before the sample reaches the preprocessor,
so ``RenderMessagesStep`` and ``SmolVLA2ChatTokenizerStep`` are
no-ops; the runtime supplies its own messages from current state.
"""
import torch # noqa: PLC0415
from lerobot.datasets.lerobot_dataset import LeRobotDataset # noqa: PLC0415
ds = LeRobotDataset(dataset_repo_id, episodes=[episode])
if len(ds) == 0:
raise ValueError(
f"Dataset {dataset_repo_id!r} episode {episode} is empty."
)
state = {"cursor": max(0, min(start_frame, len(ds) - 1))}
def _provider() -> dict | None:
idx = state["cursor"]
if advance_per_tick > 0:
state["cursor"] = (idx + advance_per_tick) % len(ds)
sample = ds[idx]
# Strip the language columns so the preprocessor's render step
# is a no-op — the runtime drives messages itself.
for k in ("language_persistent", "language_events"):
sample.pop(k, None)
if preprocessor is not None:
sample = preprocessor(sample)
# Keep only observation keys; the runtime's text path will
# merge these with its own lang_tokens / lang_masks.
observation = {
k: v
for k, v in sample.items()
if isinstance(k, str) and k.startswith("observation.")
}
# Defensive: if something further upstream forgot the batch
# dim, add it now so downstream Tensor ops don't crash.
for k, v in list(observation.items()):
if isinstance(v, torch.Tensor) and v.ndim > 0 and v.shape[0] != 1:
# ``add_batch_dim`` already ran inside the preprocessor;
# an unbatched tensor at this point means a step
# somewhere produced an unbatched output. Best-effort
# fix.
if v.shape[0] != 1 and v.ndim < 4 and "image" not in k:
observation[k] = v.unsqueeze(0)
# Move to device (the preprocessor's DeviceProcessorStep should
# already have done this when ``preprocessor is not None``;
# this is a belt-and-braces no-op in the common case).
for k, v in list(observation.items()):
if isinstance(v, torch.Tensor):
observation[k] = v.to(device)
return observation
return _provider
def _build_tools(no_tts: bool, tts_voice: str) -> dict[str, Any]:
"""Instantiate the tools declared on this dataset/policy."""
if no_tts:
return {}
@@ -133,27 +320,70 @@ def _build_tools(policy_path: Path, no_tts: bool, tts_voice: str) -> dict[str, A
return {}
def _silence_noisy_loggers() -> None:
"""Drop chatty third-party loggers down to WARNING.
HuggingFace / httpx / urllib3 emit one log line per HTTP request,
which the REPL has to print between the state block and the
prompt completely unreadable. We never need that detail in the
REPL and the user can opt back into it via ``-v`` (verbose mode
keeps DEBUG on the lerobot loggers but still gates the noisy ones
here unless they explicitly want them).
"""
for name in (
"httpcore",
"httpcore.connection",
"httpcore.http11",
"httpcore.proxy",
"httpx",
"urllib3",
"urllib3.connectionpool",
"huggingface_hub",
"huggingface_hub.repocard",
"huggingface_hub.file_download",
"transformers",
"transformers.modeling_utils",
"transformers.tokenization_utils_base",
"datasets",
"filelock",
):
logging.getLogger(name).setLevel(logging.WARNING)
def main(argv: list[str] | None = None) -> int:
args = _parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
if not args.policy_path.exists():
print(f"[smolvla2] policy path not found: {args.policy_path}", file=sys.stderr)
return 1
_silence_noisy_loggers()
print(f"[smolvla2] loading policy from {args.policy_path}", flush=True)
policy = _load_policy(args.policy_path)
policy, preprocessor, _ds_meta = _load_policy_and_preprocessor(
args.policy_path, args.dataset_repo_id
)
tools = _build_tools(args.policy_path, args.no_tts, args.tts_voice)
observation_provider: Callable[[], dict | None] | None = None
if args.dataset_repo_id is not None:
print(
f"[smolvla2] streaming observations from {args.dataset_repo_id} "
f"episode={args.dataset_episode} "
f"start_frame={args.dataset_start_frame}",
flush=True,
)
observation_provider = _build_observation_provider(
dataset_repo_id=args.dataset_repo_id,
episode=args.dataset_episode,
start_frame=args.dataset_start_frame,
advance_per_tick=args.dataset_advance_per_tick,
preprocessor=preprocessor,
device=str(getattr(policy.config, "device", "cpu")),
)
tools = _build_tools(args.no_tts, args.tts_voice)
if tools:
print(f"[smolvla2] tools loaded: {list(tools)}", flush=True)
# Robot wiring is left as a follow-up — for v1 we run language-only
# / dry-run so REPL development doesn't require a connected robot.
observation_provider = None
robot_executor = None
if not args.no_robot:
print(
@@ -162,33 +392,135 @@ def main(argv: list[str] | None = None) -> int:
flush=True,
)
from lerobot.policies.smolvla2.inference import ( # noqa: PLC0415
SmolVLA2Runtime,
StdinReader,
)
from lerobot.policies.smolvla2.inference import SmolVLA2Runtime # noqa: PLC0415
runtime = SmolVLA2Runtime(
policy=policy,
tools=tools,
observation_provider=observation_provider,
robot_executor=robot_executor,
event_collector=StdinReader().poll,
# No background event collector — the REPL drives ticks
# synchronously after each user input. The runtime's own
# ``run()`` loop is bypassed here in favour of ``step_once()``
# so the input prompt and the live state panel co-exist
# cleanly.
event_collector=None,
chunk_hz=args.chunk_hz,
ctrl_hz=args.ctrl_hz,
high_level_hz=args.high_level_hz,
)
if args.task:
runtime.set_task(args.task)
print(
"[smolvla2] runtime ready. Type a task to begin, then any line for "
"interjections, questions ending in '?' for VQA, or 'stop' to exit.",
flush=True,
)
return _run_repl(runtime, initial_task=args.task, max_ticks=args.max_ticks)
def _run_repl(runtime: Any, *, initial_task: str | None, max_ticks: int | None) -> int:
"""Claude-Code-style block REPL.
Each turn redraws a status block (task / subtask / plan / memory)
at the top, prints any robot log lines that came in since the last
turn, then asks for input on a clean ``> `` prompt at the bottom.
No live region, no panel re-renders, no rendering races with HTTP
log lines just clear-screen + reprint each turn, the way a
chat-style REPL is meant to look.
"""
try:
runtime.run(max_ticks=args.max_ticks)
from rich.console import Console # noqa: PLC0415
except ImportError:
print(
"[smolvla2] rich is required for the interactive REPL. "
"`pip install rich` and re-run.",
file=sys.stderr,
)
return 2
console = Console(highlight=False)
def _redraw(robot_lines: list[str] | None = None) -> None:
# ANSI clear screen + home cursor. Falls back gracefully on
# dumb terminals — they just see scrolled output, which is
# fine.
console.clear()
console.rule("[bold]SmolVLA2[/] · dry-run", style="cyan")
st = runtime.state
for key, label in (
("task", "task"),
("current_subtask", "subtask"),
("current_plan", "plan"),
("current_memory", "memory"),
):
value = st.get(key)
if value:
console.print(f" [bold cyan]{label:<8}[/] {value}")
else:
console.print(f" [dim]{label:<8} (not set)[/]")
queue_len = (
len(st["action_queue"])
if isinstance(st.get("action_queue"), (list, tuple))
or hasattr(st.get("action_queue"), "__len__")
else 0
)
pending = len(st.get("tool_calls_pending") or [])
console.print(
f" [dim]queued actions: {queue_len} pending tool calls: {pending}[/]"
)
console.rule(style="cyan")
if robot_lines:
for line in robot_lines:
console.print(f" [magenta]{line.strip()}[/]")
console.print()
# Help line under the divider when nothing is set yet.
if not st.get("task"):
console.print(
" [dim]Type the task to begin. Lines ending in '?' are VQA, "
"anything else is an interjection. Type 'stop' to exit.[/]"
)
last_logs: list[str] = []
_redraw()
if initial_task is None:
# Already shown the help line in _redraw when task is None.
pass
ticks_done = 0
try:
while True:
try:
line = console.input("[bold cyan]> [/]").strip()
except EOFError:
break
if not line:
_redraw(last_logs)
continue
lower = line.lower()
if lower in {"stop", "quit", "exit"}:
break
# Inject the user input as the right kind of event,
# then run a single pipeline tick to consume it.
if not runtime.state.get("task"):
task = line[5:].strip() if lower.startswith("task:") else line
runtime.set_task(task)
elif lower.endswith("?"):
runtime.state["recent_vqa_query"] = line
runtime.state.setdefault("events_this_tick", []).append(
"user_vqa_query"
)
else:
runtime.state["recent_interjection"] = line
runtime.state.setdefault("events_this_tick", []).append(
"user_interjection"
)
last_logs = runtime.step_once() or []
_redraw(last_logs)
ticks_done += 1
if max_ticks is not None and ticks_done >= max_ticks:
break
except KeyboardInterrupt:
runtime.stop()
print("\n[smolvla2] interrupted by user", flush=True)
console.print("\n[dim]interrupted[/]")
console.print("[dim]runtime stopped[/]")
return 0
+1 -1
View File
@@ -22,7 +22,7 @@ from torch.utils.data._utils.collate import default_collate
from lerobot.datasets.language import LANGUAGE_COLUMNS
_PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices"}
_PYTHON_LIST_KEYS = {"messages", "message_streams", "target_message_indices", *LANGUAGE_COLUMNS}
def lerobot_collate_fn(batch: list[dict[str, Any] | None]) -> dict[str, Any] | None: