diff --git a/README.md b/README.md
index daaebecce..7d940421b 100644
--- a/README.md
+++ b/README.md
@@ -40,6 +40,7 @@ RPent is built for four kinds of users:
- [2026/08] 🔥 RPent supports RoboCasa with RLDX-1 as manipulation model. See the [RoboCasa setup and Target50 guide](robots/robocasa/README.md) and [full documentation](https://rpent.readthedocs.io/en/latest/rst_source/usage/robocasa.html).
- [2026/08] 🔥 RPent supports the non-reasoning mode, which reduces average execution time by ~40%.
+- [2026/08] 🔥 RPent supports BEHAVIOR with Pi0.5 for long-horizon household tasks. Doc: [BEHAVIOR](https://rpent.readthedocs.io/en/latest/rst_source/usage/behavior.html).
- [2026/08] 🔥 RPent supports exploration mode for LIBERO. Doc: [LIBERO exploration mode](https://rpent.readthedocs.io/en/latest/rst_source/usage/libero.html#exploration-and-local-memory-evaluation).
- [2026/08] 🔥 RPent supports RoboTwin with LingBot-VLA for dual-arm manipulation tasks. Doc: [RoboTwin](https://rpent.readthedocs.io/en/latest/rst_source/usage/robotwin.html).
- [2026/07] 🔥 Our first RPent publication, [Harness VLA: Steering Frozen VLAs into Reliable Manipulation Primitives via Memory-Guided Agents](https://arxiv.org/abs/2607.08448), is released.
@@ -81,6 +82,7 @@ RPent is built for four kinds of users:
diff --git a/README.zh-CN.md b/README.zh-CN.md
index e2cfc69d5..462b27444 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -40,6 +40,7 @@ RPent 面向以下四类用户:
- [2026/08] 🔥 支持 RoboCasa,使用 RLDX-1 作为操作模型。参见 [RoboCasa 安装与 Target50 指南](robots/robocasa/README.md)和 [完整中文文档](https://rpent.readthedocs.io/zh-cn/latest/rst_source/usage/robocasa.html)。
- [2026/08] 🔥 新增非推理(non-reasoning)模式,平均执行时间降低约 40%。
+- [2026/08] 🔥 支持 BEHAVIOR,使用 Pi0.5 处理家庭长程任务。文档:[BEHAVIOR](https://rpent.readthedocs.io/zh-cn/latest/rst_source/usage/behavior.html)。
- [2026/08] 🔥 支持 LIBERO 探索模式。文档:[LIBERO 探索模式](https://rpent.readthedocs.io/zh-cn/latest/rst_source/usage/libero.html#memory)。
- [2026/08] 🔥 支持 RoboTwin,使用 LingBot-VLA 处理双臂操作任务。文档:[RoboTwin](https://rpent.readthedocs.io/zh-cn/latest/rst_source/usage/robotwin.html)。
- [2026/07] 🔥 RPent 首篇论文 [Harness VLA: Steering Frozen VLAs into Reliable Manipulation Primitives via Memory-Guided Agents](https://arxiv.org/abs/2607.08448) 发布。
@@ -81,6 +82,7 @@ RPent 面向以下四类用户:
|
diff --git a/docs/source-en/index.rst b/docs/source-en/index.rst
index 2d1a9f835..bba527528 100644
--- a/docs/source-en/index.rst
+++ b/docs/source-en/index.rst
@@ -54,8 +54,8 @@ Welcome to RPent
:link-type: doc
:text-align: center
- Drive the LIBERO / RoboCasa simulators or a Franka / SO-101 arm,
- switch planners, and pick action primitives.
+ Drive the LIBERO / BEHAVIOR / RoboCasa simulators or a Franka /
+ SO-101 arm, switch planners, and pick action primitives.
.. grid-item-card:: Development Tutorial
:link: rst_source/development/architecture
@@ -87,6 +87,7 @@ Welcome to RPent
LIBERO
RoboCasa
RoboTwin
+ BEHAVIOR
Franka
SO-101
Advanced Deployment
diff --git a/docs/source-en/rst_source/development/add_robot.rst b/docs/source-en/rst_source/development/add_robot.rst
index 5b6cbfd9f..3a69863e5 100644
--- a/docs/source-en/rst_source/development/add_robot.rst
+++ b/docs/source-en/rst_source/development/add_robot.rst
@@ -131,9 +131,9 @@ template, and output slug; ``runtime_components`` and ``frame_channels``
describe the robot-specific rows and camera views rendered by the
frontend. See ``robots/libero/robot_spec.py`` for the reference shape.
-That's the entire registration step — ``_resolve_robot(name)`` does an
-``importlib.import_module(f"robots.{name}")``, so dropping the package under
-``robots/`` on disk is enough. No central list to update.
+The standard source tree currently includes ``libero``, ``robocasa``,
+``robotwin``, and ``behavior`` robot packages. New robot packages should follow
+the same entry-point contract before being wired into a release.
The sections below describe what each referenced module must contain.
``_add_cli_args`` / ``_parse_config`` are covered in §4 and the runtime hook
diff --git a/docs/source-en/rst_source/development/architecture.rst b/docs/source-en/rst_source/development/architecture.rst
index 2da933700..ebcace1c4 100644
--- a/docs/source-en/rst_source/development/architecture.rst
+++ b/docs/source-en/rst_source/development/architecture.rst
@@ -158,10 +158,10 @@ Dashboard description, and three runner hooks (``add_cli_args`` /
``parse_config`` / ``init_runtime``). See :doc:`interfaces` for what each
field must provide.
-The loader itself does not maintain a list of robot names. The
-current CLI restricts ``--robot`` to ``libero`` and ``robocasa``; adding a
-new name therefore also requires updating the CLI choices. See
-:doc:`add_robot` for the complete procedure.
+The standard source tree currently includes ``libero``, ``robocasa``,
+``robotwin``, and ``behavior`` robot packages. New integrations should follow
+the same ``RobotSpec`` / ``get_toolkit`` package shape described in
+:doc:`add_robot`.
Planner, Toolkit, and RPC transports
-------------------------------------
diff --git a/docs/source-en/rst_source/installation.rst b/docs/source-en/rst_source/installation.rst
index 14e76a494..5d9ddc56c 100644
--- a/docs/source-en/rst_source/installation.rst
+++ b/docs/source-en/rst_source/installation.rst
@@ -35,6 +35,7 @@ Other environment configurations are available when needed:
pip install -e ".[robocasa]" # RoboCasa
pip install -e ".[robotwin]" # RoboTwin
+ pip install -e ".[behavior]" # BEHAVIOR
``.[libero-pro]`` is the recommended default.
@@ -51,6 +52,10 @@ Available extras:
- LIBERO-PRO + openpi Pi0.5 VLA + SAM 3.0 + RLinf runtime
* - ``.[libero-plus]``
- LIBERO-plus + openpi Pi0.5 VLA + SAM 3.0 + RLinf runtime
+ * - ``.[behavior]``
+ - RPent-side BEHAVIOR dependencies; the full OmniGibson/Isaac Sim stack
+ uses the source-editable dual-venv workflow and licensed assets documented
+ in :doc:`usage/behavior`
* - ``.[robocasa]``
- RoboCasa365 simulator + the RLDX-1 VLA; see :doc:`usage/robocasa`
* - ``.[robotwin]``
diff --git a/docs/source-en/rst_source/usage/behavior.rst b/docs/source-en/rst_source/usage/behavior.rst
new file mode 100644
index 000000000..d5aa58ace
--- /dev/null
+++ b/docs/source-en/rst_source/usage/behavior.rst
@@ -0,0 +1,296 @@
+BEHAVIOR
+========
+
+`BEHAVIOR-1K `_ provides long-horizon household
+tasks in OmniGibson. RPent exposes ``turning_on_radio`` and
+``picking_up_trash`` as a standard sibling robot plugin under
+``robots/behavior``.
+
+The integration follows the same lightweight contract as LIBERO, RoboCasa, and
+RoboTwin: ``get_robot_spec()`` supplies CLI/config/runtime hooks and
+``get_toolkit()`` supplies the public tools. BEHAVIOR-specific lifecycle code
+stays inside ``robots/behavior``. The common ``--explore`` entry point supports
+BEHAVIOR while preserving its one-attempt-per-session environment lifecycle.
+
+Installation status
+-------------------
+
+BEHAVIOR is source-editable and uses two independent Python 3.10 environments:
+
+- the **RPent venv** runs the CLI, planner, Dashboard, and MemoryManager;
+- the **BEHAVIOR venv** runs RLinf, OmniGibson, Isaac Sim, and Pi0.5.
+
+The ``.[behavior]`` extra installs only RPent-side dependencies. It does not
+install the complete simulator, assets, or checkpoint, and a normal wheel does
+not promise a directly runnable BEHAVIOR stack. From a source checkout, run:
+
+.. code-block:: bash
+
+ python -m pip install -e ".[behavior]"
+ export RPENT_REPRO_ROOT="$PWD/.behavior-runtime"
+ export UV_CACHE_DIR="$RPENT_REPRO_ROOT/uv-cache"
+ behavior-install-runtime
+
+The installer keeps RPent editable in both venvs, clones the reviewed RLinf
+revision, invokes the official RLinf BEHAVIOR installer, applies the reviewed
+CUDA/OpenPI compatibility pins, verifies critical imports and CUDA, and writes
+freezes plus source identities under ``$RPENT_REPRO_ROOT/manifests``. Use a new
+``RPENT_REPRO_ROOT`` for a fresh install; the script refuses to overwrite a
+wrong or dirty RLinf checkout.
+
+Motion planning uses NVlabs/cuRobo v0.8.0 at commit
+``4ea77366ca48ee453e7df139e39fa6532af49f3b`` in the BEHAVIOR venv.
+The installer applies constraints before its final repin, retaining NumPy
+1.26.4, Torch 2.5.1+cu124 and Isaac Sim 4.5.0.0. Do not install cuRobo with
+an unconstrained resolver: a NumPy 2 upgrade is incompatible with this stack.
+
+Simulator assets
+----------------
+
+Accept the BEHAVIOR/OmniGibson licences, choose a dedicated data root, and use
+the standard asset command. It invokes the three official OmniGibson download
+functions in the BEHAVIOR venv rather than importing OmniGibson into the RPent
+environment:
+
+.. code-block:: bash
+
+ export OMNIGIBSON_DATA_PATH=/path/to/BEHAVIOR-1K-datasets
+ export BEHAVIOR_PYTHON="$RPENT_REPRO_ROOT/venvs/behavior/bin/python"
+ behavior-download-assets --accept-license --skip-existing
+
+Omit ``--accept-license`` to let the official downloader display its
+interactive licence prompt. The flag is an explicit non-interactive
+confirmation; do not use it unless you accept the licence terms.
+
+The final data root must contain:
+
+.. code-block:: text
+
+ BEHAVIOR-1K-datasets/
+ 2025-challenge-task-instances/
+ behavior-1k-assets/
+ scenes/
+ omnigibson-robot-assets/
+ omnigibson.key
+
+Pi0.5 checkpoint
+----------------
+
+Download the reviewed checkpoint into a directory outside the source tree:
+
+.. code-block:: bash
+
+ export PI05_CHECKPOINT_PATH=/path/to/RLinf-Pi05-BEHAVIOR-1K-PT50-CS32
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/hf" download \
+ RLinf/RLinf-Pi05-BEHAVIOR-1K-PT50-CS32 \
+ --local-dir "$PI05_CHECKPOINT_PATH"
+
+``behavior-download-assets --verify`` verifies the required OmniGibson layout
+and the source-controlled checkpoint size/SHA binding. The shared #136 Pi0.5
+component receives head, left-wrist, right-wrist, and raw R1Pro proprio data.
+The raw RPC result is ``[1, 32, 23]``; the common client returns ``[32, 23]``.
+
+.. code-block:: bash
+
+ behavior-download-assets --verify
+
+DINOv2 configuration
+--------------------
+
+BEHAVIOR keeps a reviewed `DINOv2 `_
+ViT-S/14 deployment for whole-image embeddings and episode-memory retrieval.
+Provide a DINOv2 source archive and the ``dinov2_vits14_pretrain.pth`` weights:
+
+.. code-block:: bash
+
+ export DINOV2_SOURCE_ARCHIVE=/path/to/dinov2-source.tar.gz
+ export DINOV2_WEIGHTS=/path/to/dinov2_vits14_pretrain.pth
+
+ curl -L \
+ https://github.com/facebookresearch/dinov2/archive/7764ea0f912e53c92e82eb78a2a1631e92725fc8.tar.gz \
+ -o "$DINOV2_SOURCE_ARCHIVE"
+ curl -L \
+ https://dl.fbaipublicfiles.com/dinov2/dinov2_vits14/dinov2_vits14_pretrain.pth \
+ -o "$DINOV2_WEIGHTS"
+
+DINOv2 is the shared visual-memory component. It is not a segmentation model,
+does not replace SAM3 masks, and does not replace current public observations
+or MemoryManager Markdown/YAML material. The accepted DINOv2 source revision
+and both asset SHA-256 identities are pinned in
+``robots/behavior/dino_v2/encoder.py``; the runtime rejects mismatched assets.
+
+The DINO episode-memory catalog is a rebuildable derivative of official
+BEHAVIOR demonstration data. Build it with the standard command below; do not
+edit the catalog by hand.
+
+.. code-block:: bash
+
+ behavior-build-memory \
+ --selection-manifest /path/to/selection_manifest.json \
+ --video-root /path/to/2025-challenge-demos \
+ --rollups-dir /path/to/episode_rollups \
+ --source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --weights "$DINOV2_WEIGHTS" \
+ --cuda-device 2 \
+ --output-dir /path/to/behavior-dino-catalog
+
+Task identity
+-------------
+
+Use ``--task-name`` and ``--public-seed``. Public seeds map to fixed official
+activity instances through ``robots/behavior/task_specs.py``.
+
+.. list-table::
+ :header-rows: 1
+ :widths: 24 42 16 18
+
+ * - Task
+ - Instruction
+ - Explore seeds
+ - Eval seeds
+ * - ``turning_on_radio``
+ - Turn on the radio receiver on the living-room table.
+ - ``0``
+ - ``1``-``9``
+ * - ``picking_up_trash``
+ - Put the three living-room soda cans into the kitchen trash can.
+ - ``0``-``9``
+ - ``10``-``19``
+
+One evaluation run
+------------------
+
+Bind each CUDA child to one physical GPU explicitly:
+
+.. code-block:: bash
+
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" --robot behavior \
+ --task-name turning_on_radio --public-seed 1 \
+ --behavior-mode eval \
+ --planner codex --model gpt-5.5 \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_REPRO_ROOT/venvs/behavior/bin/python" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --behavior-env-cuda-device 0 \
+ --behavior-model-cuda-device 1 \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS" \
+ --memory-profile local \
+ --memory-dir /path/to/behavior-memory \
+ --behavior-memory-dir /path/to/reviewed-behavior-episode-memory
+
+The first environment load can take several minutes. The environment, VLA, and
+DINO are separate processes, and each receives only its explicitly selected GPU.
+
+Official MemoryManager
+----------------------
+
+BEHAVIOR uses the same Markdown/YAML ``MemoryManager`` format and common memory
+tools as the other robots. The DINO episode-memory catalog is a separate visual
+experience retrieval source; when configured, its advisory is attached to
+public tool receipts and remains historical guidance only.
+
+- Eval creates one ``MemoryManager`` with ``read_only`` access.
+- Explore creates one ``MemoryManager`` with ``inbox_write`` access scoped to
+ ``/_internal/inbox/``.
+- ``MEMORY.md``, ``global/``, ``suite/``, and ``task_only/`` hold the published
+ corpus. Solved audit/recipe pairs are copied to ``task_only/``.
+- When merge processes a valid root-level draft, the cell inbox is archived to
+ ``_internal/merged/``. An inbox containing only invalid drafts
+ stays in place; conflicting prose is archived under ``_internal/conflicts/``.
+
+An absent or empty corpus is valid, but it contains no advice. Pass the same
+explicit ``--memory-dir`` to runs that should share reviewed memory.
+
+Use ``--behavior-memory-dir`` only for the reviewed DINO episode-memory catalog.
+Omitting it selects a legal empty episode catalog and does not download or
+silently substitute task-specific memory.
+
+Use the standard RPent Explore entry point for a bounded sequence of sessions:
+
+.. code-block:: bash
+
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" --robot behavior \
+ --behavior-mode explore \
+ --explore \
+ --explore-sessions 3 \
+ --task-name picking_up_trash --public-seed 0 \
+ --output-dir /path/to/behavior-explore \
+ --memory-dir /path/to/behavior-memory \
+ --planner codex --model gpt-5.5 \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_REPRO_ROOT/venvs/behavior/bin/python" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --behavior-env-cuda-device 0 \
+ --behavior-model-cuda-device 1 \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS"
+
+For BEHAVIOR, one session is exactly one attempt. Each session starts a fresh
+environment sidecar, episode, and ``sessions/session_NNN`` output directory;
+the VLA and DINO sidecars stay shared across sessions. The planner cannot reset
+inside an invocation, and ``--explore-attempts-per-session`` values above zero
+are rejected.
+
+Runtime and Dashboard
+---------------------
+
+The runtime has four component roles:
+
+- ``env``: the task-scoped official BEHAVIOR/OmniGibson environment;
+- ``vla``: the shared ``rpent/robots/components/pi05_vla_server.py`` service;
+- ``dino``: the shared ``robots/behavior/dino_v2/server.py`` episode-memory
+ embedding service;
+- ``memory``: the task-scoped official MemoryManager.
+
+Start a Dashboard Session with:
+
+.. code-block:: bash
+
+ export RPENT_BEHAVIOR_PYTHON="$RPENT_REPRO_ROOT/venvs/behavior/bin/python"
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" \
+ --robot behavior --dashboard \
+ --task-name turning_on_radio --public-seed 1 \
+ --behavior-mode eval \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_BEHAVIOR_PYTHON" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS" \
+ --memory-profile local --memory-dir /path/to/behavior-memory \
+ --output-dir /path/to/behavior-dashboard-run
+
+The Dashboard uses the common Start Session flow and head/left-wrist/right-
+wrist camera views. BEHAVIOR does not add robot-local manual buttons, a manual
+control backend, or ``env.dashboard_*`` RPC methods. The public contract
+registers nine executable planner primitives: ``pi0_nav_pick``, ``observe``,
+``pixel_to_world``, ``navigate_to``, ``move_to``, ``rotate_wrist``, ``close``,
+``open``, and ``press``. ``move_to(hand=both)`` coordinates both arms through
+cuRobo collision-checked trajectories; wrist rotation uses the same planner.
+Navigation executes a bounded straight base segment or rotation and rejects
+obstructed paths. RGB-D projections use the current physical camera frame;
+R1Pro has no movable head camera, so non-center ``head_view`` presets are rejected.
+``press`` advances an already aligned hand
+at most 2 cm for at most 10 seconds, stopping on external contact or episode end.
+Contact is not verified button contact; visual hand checks remain unverified.
+Planning, collision, tracking and duration failures are reported explicitly.
+Only raw ``info["done"]["success"]`` establishes task success. Motion primitives
+return their final observation for the next policy call and streaming video;
+VLA chunks additionally record each returned environment frame.
+
+The main logs are:
+
+.. code-block:: text
+
+ /run.log
+ /behavior_vla_server.log
+ /behavior_dino_server.log
+ /tasks//behavior_env_server.log
+ /tasks//episode.mp4
+ /tasks//terminal_receipt.json
diff --git a/docs/source-zh/index.rst b/docs/source-zh/index.rst
index 8740cfdd3..302258908 100644
--- a/docs/source-zh/index.rst
+++ b/docs/source-zh/index.rst
@@ -46,7 +46,7 @@
:link-type: doc
:text-align: center
- 使用 LIBERO / RoboCasa 仿真环境或 Franka / SO-101 机械臂,
+ 使用 LIBERO / BEHAVIOR / RoboCasa 仿真环境或 Franka / SO-101 机械臂,
切换 planner 并选择动作原语。
.. grid-item-card:: 开发教程
@@ -79,6 +79,7 @@
LIBERO
RoboCasa
RoboTwin
+ BEHAVIOR
Franka
SO-101
高级部署
diff --git a/docs/source-zh/rst_source/development/add_robot.rst b/docs/source-zh/rst_source/development/add_robot.rst
index 2ecc2dcf1..5b236ac02 100644
--- a/docs/source-zh/rst_source/development/add_robot.rst
+++ b/docs/source-zh/rst_source/development/add_robot.rst
@@ -120,8 +120,8 @@ RPent 的整体进程划分、服务职责和通信方式见 :doc:`系统设计
slug,``runtime_components`` 与 ``frame_channels`` 描述前端展示的环境专用服务行
和相机视图。完整结构参考 ``robots/libero/robot_spec.py``。
-``_resolve_robot(name)`` 通过 ``importlib.import_module(f"robots.{name}")``
-动态加载机器人包。因此,只需将机器人包放在 ``robots/`` 下,无需维护中央注册列表。
+标准源码树当前包含 ``libero``、``robocasa``、``robotwin`` 和 ``behavior``
+robot package。新的 robot package 应先沿用相同入口契约,再接入发布版本。
下文依次说明这些模块需要实现的内容。``_add_cli_args`` 和 ``_parse_config``
见第 4 节,runtime 钩子见第 5 节。Dashboard spec 只由 Dashboard runner 使用。
diff --git a/docs/source-zh/rst_source/development/architecture.rst b/docs/source-zh/rst_source/development/architecture.rst
index 1b909410a..a1c63d73c 100644
--- a/docs/source-zh/rst_source/development/architecture.rst
+++ b/docs/source-zh/rst_source/development/architecture.rst
@@ -138,9 +138,9 @@ planner 后端集中在 ``rpent/planner/``,
钩子(``add_cli_args`` / ``parse_config`` / ``init_runtime``)。各字段要填什么见
:doc:`interfaces`。
-加载器本身不维护机器人名称列表。当前 CLI 将 ``--robot`` 限定为 ``libero``
-和 ``robocasa``;接入新的机器人名称时,还需要同步更新 CLI 的可选值。完整步骤见
-:doc:`add_robot`。
+标准源码树当前包含 ``libero``、``robocasa``、``robotwin`` 和 ``behavior``
+robot package。新的集成应沿用 :doc:`add_robot` 中说明的 ``RobotSpec`` /
+``get_toolkit`` package 结构。
Planner、Toolkit 与 RPC 传输层
------------------------------
diff --git a/docs/source-zh/rst_source/installation.rst b/docs/source-zh/rst_source/installation.rst
index 786300518..279a6c50f 100644
--- a/docs/source-zh/rst_source/installation.rst
+++ b/docs/source-zh/rst_source/installation.rst
@@ -42,6 +42,7 @@ RPent 可以通过一条 ``pip install`` 命令完成安装,并提供多种可
pip install -e ".[robocasa]" # RoboCasa
pip install -e ".[robotwin]" # RoboTwin
+ pip install -e ".[behavior]" # BEHAVIOR
``.[libero-pro]`` 是默认推荐的依赖组合。
@@ -58,6 +59,10 @@ RPent 可以通过一条 ``pip install`` 命令完成安装,并提供多种可
- LIBERO-PRO + openpi Pi0.5 VLA + SAM 3.0 + RLinf 运行时
* - ``.[libero-plus]``
- LIBERO-plus + openpi Pi0.5 VLA + SAM 3.0 + RLinf 运行时
+ * - ``.[behavior]``
+ - BEHAVIOR 的 RPent 侧依赖;完整 OmniGibson/Isaac Sim 运行环境需使用
+ 源码 editable 双 venv 流程及受许可约束的仿真资产,详见
+ :doc:`usage/behavior`
* - ``.[robocasa]``
- RoboCasa365 仿真器 + RLDX-1 VLA,详见 :doc:`usage/robocasa`
* - ``.[robotwin]``
diff --git a/docs/source-zh/rst_source/usage/behavior.rst b/docs/source-zh/rst_source/usage/behavior.rst
new file mode 100644
index 000000000..71126ebb6
--- /dev/null
+++ b/docs/source-zh/rst_source/usage/behavior.rst
@@ -0,0 +1,279 @@
+BEHAVIOR
+========
+
+`BEHAVIOR-1K `_ 基于 OmniGibson 提供长程家庭任务。
+RPent 当前把 ``turning_on_radio`` 和 ``picking_up_trash`` 作为标准 sibling
+robot plugin 接入,源码位于 ``robots/behavior``。
+
+接入合同与 LIBERO、RoboCasa、RoboTwin 相同:``get_robot_spec()`` 提供
+CLI/config/runtime hooks,``get_toolkit()`` 提供公开工具。BEHAVIOR 生命周期逻辑
+留在 ``robots/behavior`` 内。公共 ``--explore`` 入口现已支持 BEHAVIOR,同时保留
+每个 session 只运行一个 attempt 的环境生命周期。
+
+安装状态
+--------
+
+BEHAVIOR 以源码 editable 方式运行,并使用两个相互独立的 Python 3.10 环境:
+
+- **RPent venv**:运行 CLI、planner、Dashboard 和 MemoryManager;
+- **BEHAVIOR venv**:运行 RLinf、OmniGibson、Isaac Sim 和 Pi0.5。
+
+``.[behavior]`` 只安装 RPent 侧依赖,不包含完整模拟器、资产或 checkpoint;
+普通 wheel 不承诺可直接运行 BEHAVIOR。请在源码 checkout 中执行:
+
+.. code-block:: bash
+
+ python -m pip install -e ".[behavior]"
+ export RPENT_REPRO_ROOT="$PWD/.behavior-runtime"
+ export UV_CACHE_DIR="$RPENT_REPRO_ROOT/uv-cache"
+ behavior-install-runtime
+
+安装器会在两个 venv 中保持 RPent editable,克隆已审查的 RLinf revision,调用
+官方 RLinf BEHAVIOR 安装器,应用已审查的 CUDA/OpenPI 兼容性 pin,验证关键 import
+和 CUDA,并在 ``$RPENT_REPRO_ROOT/manifests`` 写入 freeze 与源码身份。fresh install
+应使用新的 ``RPENT_REPRO_ROOT``;脚本不会覆盖 revision 错误或 dirty 的 RLinf
+checkout。
+
+运动规划在 BEHAVIOR venv 中使用 NVlabs/cuRobo v0.8.0,固定 commit
+``4ea77366ca48ee453e7df139e39fa6532af49f3b``。安装器在最终 repin 前使用
+constraints,保留 NumPy 1.26.4、Torch 2.5.1+cu124 和 Isaac Sim 4.5.0.0。
+不要无约束安装 cuRobo:resolver 升级到 NumPy 2 会破坏此环境的兼容性。
+
+仿真资产
+--------
+
+接受 BEHAVIOR/OmniGibson 许可后,选择独立数据根并使用标准资产命令。该命令会在
+BEHAVIOR venv 中调用三个 OmniGibson 官方下载函数,不会把 OmniGibson import 到
+RPent 环境:
+
+.. code-block:: bash
+
+ export OMNIGIBSON_DATA_PATH=/path/to/BEHAVIOR-1K-datasets
+ export BEHAVIOR_PYTHON="$RPENT_REPRO_ROOT/venvs/behavior/bin/python"
+ behavior-download-assets --accept-license --skip-existing
+
+不传 ``--accept-license`` 时,官方下载器会显示交互式许可确认。该参数代表明确的
+非交互许可确认;仅在接受许可条款后使用。
+
+最终数据根必须包含:
+
+.. code-block:: text
+
+ BEHAVIOR-1K-datasets/
+ 2025-challenge-task-instances/
+ behavior-1k-assets/
+ scenes/
+ omnigibson-robot-assets/
+ omnigibson.key
+
+Pi0.5 checkpoint
+----------------
+
+将已审查 checkpoint 下载到源码树之外:
+
+.. code-block:: bash
+
+ export PI05_CHECKPOINT_PATH=/path/to/RLinf-Pi05-BEHAVIOR-1K-PT50-CS32
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/hf" download \
+ RLinf/RLinf-Pi05-BEHAVIOR-1K-PT50-CS32 \
+ --local-dir "$PI05_CHECKPOINT_PATH"
+
+``behavior-download-assets --verify`` 会检查 OmniGibson 必需目录,以及源码中固定的
+checkpoint size/SHA binding。#136 的共享 Pi0.5 component 接收 head、left wrist、
+right wrist 和 raw R1Pro proprio;原始 RPC 输出为 ``[1, 32, 23]``,公共 client
+返回 ``[32, 23]``。
+
+.. code-block:: bash
+
+ behavior-download-assets --verify
+
+DINOv2 配置
+-------------
+
+BEHAVIOR 保留经审查的 `DINOv2 `_
+ViT-S/14 部署,用于整图 embedding 与 episode memory 检索。运行前提供 DINOv2
+源码归档和 ``dinov2_vits14_pretrain.pth`` 权重:
+
+.. code-block:: bash
+
+ export DINOV2_SOURCE_ARCHIVE=/path/to/dinov2-source.tar.gz
+ export DINOV2_WEIGHTS=/path/to/dinov2_vits14_pretrain.pth
+
+ curl -L \
+ https://github.com/facebookresearch/dinov2/archive/7764ea0f912e53c92e82eb78a2a1631e92725fc8.tar.gz \
+ -o "$DINOV2_SOURCE_ARCHIVE"
+ curl -L \
+ https://dl.fbaipublicfiles.com/dinov2/dinov2_vits14/dinov2_vits14_pretrain.pth \
+ -o "$DINOV2_WEIGHTS"
+
+DINOv2 是共享视觉 memory component;它不是分割模型,不替代 SAM3 mask、当前
+公开观察或 MemoryManager 的 Markdown/YAML 语料。接受的源码 revision 和两个资产
+SHA-256 固定在 ``robots/behavior/dino_v2/encoder.py``;runtime 会拒绝不匹配的资产。
+
+DINO episode-memory catalog 是官方 BEHAVIOR demonstration 数据的可重建派生物。
+使用下面的标准命令生成;不要手工编辑 catalog。
+
+.. code-block:: bash
+
+ behavior-build-memory \
+ --selection-manifest /path/to/selection_manifest.json \
+ --video-root /path/to/2025-challenge-demos \
+ --rollups-dir /path/to/episode_rollups \
+ --source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --weights "$DINOV2_WEIGHTS" \
+ --cuda-device 2 \
+ --output-dir /path/to/behavior-dino-catalog
+
+任务身份
+--------
+
+使用 ``--task-name`` 和 ``--public-seed``。public seed 通过
+``robots/behavior/task_specs.py`` 固定映射到官方 activity instance。
+
+.. list-table::
+ :header-rows: 1
+ :widths: 24 42 16 18
+
+ * - 任务
+ - 指令
+ - Explore seeds
+ - Eval seeds
+ * - ``turning_on_radio``
+ - 打开客厅桌上的收音机。
+ - ``0``
+ - ``1``-``9``
+ * - ``picking_up_trash``
+ - 把客厅的三个汽水罐放进厨房垃圾桶。
+ - ``0``-``9``
+ - ``10``-``19``
+
+运行一次 Eval
+-------------
+
+每个 CUDA 子进程必须显式绑定一个物理 GPU:
+
+.. code-block:: bash
+
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" --robot behavior \
+ --task-name turning_on_radio --public-seed 1 \
+ --behavior-mode eval \
+ --planner codex --model gpt-5.5 \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_REPRO_ROOT/venvs/behavior/bin/python" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --behavior-env-cuda-device 0 \
+ --behavior-model-cuda-device 1 \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS" \
+ --memory-profile local \
+ --memory-dir /path/to/behavior-memory \
+ --behavior-memory-dir /path/to/reviewed-behavior-episode-memory
+
+首次加载环境通常需要数分钟。env、VLA 和 DINO 是不同进程,每个进程只接收自己
+显式选择的 GPU。
+
+官方 MemoryManager
+------------------
+
+BEHAVIOR 使用和其他机器人相同的 Markdown/YAML ``MemoryManager`` 格式与公共 memory
+工具。DINO episode-memory catalog 是独立的视觉经验检索源;配置后,它的 advisory
+会附加到公开 tool receipt,并始终只作为历史建议。
+
+- Eval 只构造一个 ``read_only`` MemoryManager;
+- Explore 只构造一个 ``inbox_write`` MemoryManager,写入范围限定为
+ ``/_internal/inbox/``;
+- ``MEMORY.md``、``global/``、``suite/`` 和 ``task_only/`` 保存已发布语料,
+ 成功的 audit/recipe 对复制到 ``task_only/``;
+- merge 处理有效的根级草稿后,将该 cell 的 inbox 归档到
+ ``_internal/merged/``。只有无效草稿的 inbox 保留原位;
+ 冲突文本归档到 ``_internal/conflicts/``。
+
+缺失或空 corpus 是合法状态,但不会提供任何建议。需要共享已审查 memory 的运行应
+显式传入同一个 ``--memory-dir``。
+
+``--behavior-memory-dir`` 只用于经审查的 DINO episode-memory catalog。省略该参数
+会选择合法的空 episode catalog,不会下载或静默替换为特定任务 memory。
+
+使用标准 RPent Explore 入口运行一组有界 session:
+
+.. code-block:: bash
+
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" --robot behavior \
+ --behavior-mode explore \
+ --explore \
+ --explore-sessions 3 \
+ --task-name picking_up_trash --public-seed 0 \
+ --output-dir /path/to/behavior-explore \
+ --memory-dir /path/to/behavior-memory \
+ --planner codex --model gpt-5.5 \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_REPRO_ROOT/venvs/behavior/bin/python" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --behavior-env-cuda-device 0 \
+ --behavior-model-cuda-device 1 \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS"
+
+对 BEHAVIOR 而言,一个 session 就是一个 attempt。每个 session 都启动 fresh env
+sidecar、new episode,并写入独立的 ``sessions/session_NNN`` 目录;VLA 与 DINO
+sidecar 在各 session 之间共享。planner 无权在单次 invocation 内 reset,且
+``--explore-attempts-per-session`` 大于零会被拒绝。
+
+Runtime 与 Dashboard
+--------------------
+
+runtime 有四个 component role:
+
+- ``env``:task-scoped 官方 BEHAVIOR/OmniGibson 环境;
+- ``vla``:共享 ``rpent/robots/components/pi05_vla_server.py`` 服务;
+- ``dino``:共享 ``robots/behavior/dino_v2/server.py`` episode-memory
+ embedding 服务;
+- ``memory``:task-scoped 官方 MemoryManager。
+
+启动 Dashboard Session:
+
+.. code-block:: bash
+
+ export RPENT_BEHAVIOR_PYTHON="$RPENT_REPRO_ROOT/venvs/behavior/bin/python"
+ "$RPENT_REPRO_ROOT/venvs/rpent/bin/rpent" \
+ --robot behavior --dashboard \
+ --task-name turning_on_radio --public-seed 1 \
+ --behavior-mode eval \
+ --behavior-repo "$RPENT_REPRO_ROOT/RLinf" \
+ --behavior-python "$RPENT_BEHAVIOR_PYTHON" \
+ --activity-instance-dir \
+ "$OMNIGIBSON_DATA_PATH/2025-challenge-task-instances" \
+ --policy-checkpoint "$PI05_CHECKPOINT_PATH" \
+ --dino-source-archive "$DINOV2_SOURCE_ARCHIVE" \
+ --dino-weights "$DINOV2_WEIGHTS" \
+ --memory-profile local --memory-dir /path/to/behavior-memory \
+ --output-dir /path/to/behavior-dashboard-run
+
+Dashboard 使用公共 Start Session 流程与 head/left-wrist/right-wrist 相机视图。
+BEHAVIOR 不增加 robot-local 手动按钮、手动控制 backend 或
+``env.dashboard_*`` RPC。公开合同注册 9 个可执行 planner primitive:
+``pi0_nav_pick``、``observe``、``pixel_to_world``、``navigate_to``、``move_to``、
+``rotate_wrist``、``close``、``open`` 和 ``press``。
+``move_to(hand=both)`` 通过 cuRobo 碰撞检查轨迹协调双臂,腕旋转复用同一规划器。
+导航执行有界直线底盘运动或转向,路径被阻挡时拒绝执行。
+RGB-D 投影使用当前物理相机帧;R1Pro 没有可动头部相机,因此拒绝非 center 的
+``head_view`` 预设。``press`` 沿已对准的手部方向推进,最多 2 cm、10 秒,
+遇外部接触或 episode 结束即停;接触不等于已验证按钮接触,视觉手部检查仍未验证。
+规划、碰撞、跟踪和时长限制导致的失败均明确返回。
+任务成功仅认原始 ``info["done"]["success"]``。运动原语返回最终观测供后续策略调用
+和流式视频使用;VLA chunk 另外记录每个实际返回的环境帧。
+
+主要日志:
+
+.. code-block:: text
+
+ /run.log
+ /behavior_vla_server.log
+ /behavior_dino_server.log
+ /tasks//behavior_env_server.log
+ /tasks//episode.mp4
+ /tasks//terminal_receipt.json
diff --git a/pyproject.toml b/pyproject.toml
index c46a82160..63afad07b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -45,6 +45,9 @@ dependencies = [
[project.scripts]
rpent = "rpent.cli.main:main"
rpent-memory = "rpent.cli.memory:main"
+behavior-download-assets = "robots.behavior.assets_cli:main"
+behavior-build-memory = "robots.behavior.build_memory_cli:main"
+behavior-install-runtime = "robots.behavior.install_runtime:main"
[project.urls]
Homepage = "https://github.com/RLinf/RPent"
@@ -56,6 +59,7 @@ Documentation = "https://rpent.readthedocs.io/en/latest/"
test = [
"pytest",
"pytest-timeout",
+ "scipy>=1.10,<2",
]
rlinf = [
"rpent-rlinf",
@@ -68,6 +72,17 @@ sam3 = [
"torchvision",
"pillow>=10",
]
+behavior = [
+ "scipy>=1.10,<2",
+ "rpent[rlinf]",
+ "rpent-openpi @ git+https://github.com/RLinf/openpi.git@rpent",
+ # OpenPI's tool.uv source is not propagated through package metadata.
+ "lerobot @ git+https://github.com/huggingface/lerobot.git@0cf864870cf29f4738d3ade893e6fd13fbd7cdb5",
+ "torch",
+ "torchvision",
+ "pillow>=10",
+ "imageio-ffmpeg>=0.5",
+]
libero = [
"rpent[rlinf]",
"rpent[sam3]",
@@ -115,7 +130,7 @@ include-package-data = true
[tool.setuptools.packages.find]
where = ["."]
-include = ["rpent*"]
+include = ["rpent*", "robots", "robots.behavior*"]
exclude = ["tests*", "docs*", "examples*", "docker*", "toolkits*"]
[tool.setuptools.package-data]
diff --git a/robots/behavior/__init__.py b/robots/behavior/__init__.py
new file mode 100644
index 000000000..db7e30c76
--- /dev/null
+++ b/robots/behavior/__init__.py
@@ -0,0 +1,19 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR robot extension."""
+
+from robots.behavior.robot_spec import get_robot_spec, get_toolkit
+
+__all__ = ["get_robot_spec", "get_toolkit"]
diff --git a/robots/behavior/assets_cli.py b/robots/behavior/assets_cli.py
new file mode 100644
index 000000000..7e5a568e0
--- /dev/null
+++ b/robots/behavior/assets_cli.py
@@ -0,0 +1,278 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Download and verify BEHAVIOR assets.
+
+This command requires an RPent source checkout with ``robots.behavior``
+available through editable install.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import subprocess
+from collections.abc import Sequence
+from pathlib import Path
+
+from robots.behavior.dino_v2.encoder import (
+ EXPECTED_SOURCE_ARCHIVE_SHA256,
+ EXPECTED_WEIGHTS_SHA256,
+)
+from robots.behavior.policy_checkpoint import validate_policy_checkpoint
+
+_DOWNLOAD_SNIPPET = """
+import sys
+
+from omnigibson.utils.asset_utils import (
+ download_2025_challenge_task_instances,
+ download_behavior_1k_assets,
+ download_omnigibson_robot_assets,
+)
+
+actions = set(filter(None, sys.argv[1].split(",")))
+accept_license = sys.argv[2] == "1"
+if "robot" in actions:
+ download_omnigibson_robot_assets()
+if "behavior" in actions:
+ download_behavior_1k_assets(accept_license=accept_license)
+if "challenge" in actions:
+ download_2025_challenge_task_instances()
+"""
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="behavior-download-assets",
+ description=(
+ "Download BEHAVIOR assets through the official OmniGibson API, "
+ "or verify an existing installation."
+ ),
+ )
+ parser.add_argument(
+ "--behavior-python",
+ type=Path,
+ default=None,
+ help=(
+ "Python from the BEHAVIOR venv. Defaults to BEHAVIOR_PYTHON, "
+ "RPENT_BEHAVIOR_PYTHON, or $RPENT_REPRO_ROOT/venvs/behavior/bin/python."
+ ),
+ )
+ parser.add_argument(
+ "--data-path",
+ type=Path,
+ default=None,
+ help="BEHAVIOR data root (defaults to OMNIGIBSON_DATA_PATH).",
+ )
+ parser.add_argument(
+ "--checkpoint",
+ type=Path,
+ default=None,
+ help="Pi0.5 checkpoint directory (defaults to PI05_CHECKPOINT_PATH).",
+ )
+ parser.add_argument(
+ "--dino-source-archive",
+ type=Path,
+ default=None,
+ help="Pinned DINOv2 source archive (defaults to DINOV2_SOURCE_ARCHIVE).",
+ )
+ parser.add_argument(
+ "--dino-weights",
+ type=Path,
+ default=None,
+ help="DINOv2 ViT-S/14 weights (defaults to DINOV2_WEIGHTS).",
+ )
+ parser.add_argument(
+ "--accept-license",
+ action="store_true",
+ help=(
+ "Confirm acceptance of the BEHAVIOR data licence non-interactively. "
+ "Without this flag, the official downloader prompts when needed."
+ ),
+ )
+ parser.add_argument(
+ "--skip-existing",
+ action="store_true",
+ help="Do not call a downloader for an asset directory already present.",
+ )
+ parser.add_argument(
+ "--verify",
+ action="store_true",
+ help="Verify existing simulator, checkpoint, and DINO assets without downloading.",
+ )
+ return parser
+
+
+def _env_path(value: Path | None, *names: str) -> Path | None:
+ if value is not None:
+ return value.expanduser().resolve()
+ for name in names:
+ configured = os.environ.get(name)
+ if configured:
+ return Path(configured).expanduser().resolve()
+ return None
+
+
+def _behavior_python(value: Path | None) -> Path:
+ resolved = _env_path(value, "BEHAVIOR_PYTHON", "RPENT_BEHAVIOR_PYTHON")
+ if resolved is None:
+ repro_root = os.environ.get("RPENT_REPRO_ROOT")
+ if repro_root:
+ resolved = (
+ Path(repro_root).expanduser().resolve()
+ / "venvs"
+ / "behavior"
+ / "bin"
+ / "python"
+ )
+ if resolved is None or not resolved.is_file():
+ raise ValueError(
+ "BEHAVIOR Python is unavailable; pass --behavior-python or set "
+ "BEHAVIOR_PYTHON"
+ )
+ return resolved
+
+
+def _require_data_path(value: Path | None) -> Path:
+ resolved = _env_path(value, "OMNIGIBSON_DATA_PATH")
+ if resolved is None:
+ raise ValueError(
+ "BEHAVIOR data root is unavailable; pass --data-path or set "
+ "OMNIGIBSON_DATA_PATH"
+ )
+ return resolved
+
+
+def _require_file(path: Path | None, *, label: str) -> Path:
+ if path is None or not path.is_file():
+ raise ValueError(f"missing required {label}: {path}")
+ return path
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def verify_assets(
+ *,
+ data_path: Path,
+ checkpoint: Path | None,
+ dino_source_archive: Path | None,
+ dino_weights: Path | None,
+) -> None:
+ required_directories = (
+ data_path / "behavior-1k-assets" / "scenes",
+ data_path / "omnigibson-robot-assets",
+ data_path / "2025-challenge-task-instances",
+ )
+ for path in required_directories:
+ if not path.is_dir():
+ raise ValueError(f"missing required directory: {path}")
+ _require_file(data_path / "omnigibson.key", label="OmniGibson licence key")
+
+ if checkpoint is None:
+ raise ValueError(
+ "Pi0.5 checkpoint is unavailable; pass --checkpoint or set "
+ "PI05_CHECKPOINT_PATH"
+ )
+ validate_policy_checkpoint(checkpoint)
+
+ source = _require_file(dino_source_archive, label="DINOv2 source archive")
+ weights = _require_file(dino_weights, label="DINOv2 weights")
+ source_sha256 = _sha256_file(source)
+ if source_sha256 != EXPECTED_SOURCE_ARCHIVE_SHA256:
+ raise ValueError(
+ "DINOv2 source archive SHA-256 mismatch: "
+ f"expected {EXPECTED_SOURCE_ARCHIVE_SHA256}, got {source_sha256}"
+ )
+ weights_sha256 = _sha256_file(weights)
+ if weights_sha256 != EXPECTED_WEIGHTS_SHA256:
+ raise ValueError(
+ "DINOv2 weights SHA-256 mismatch: "
+ f"expected {EXPECTED_WEIGHTS_SHA256}, got {weights_sha256}"
+ )
+ print("BEHAVIOR assets: OK")
+
+
+def download_assets(
+ *,
+ behavior_python: Path,
+ data_path: Path,
+ accept_license: bool,
+ skip_existing: bool,
+) -> None:
+ actions = ["robot", "behavior", "challenge"]
+ if skip_existing:
+ present = {
+ "robot": (data_path / "omnigibson-robot-assets").is_dir(),
+ "behavior": (
+ (data_path / "behavior-1k-assets").is_dir()
+ and (data_path / "omnigibson.key").is_file()
+ ),
+ "challenge": (data_path / "2025-challenge-task-instances").is_dir(),
+ }
+ actions = [name for name in actions if not present[name]]
+ if not actions:
+ print("BEHAVIOR assets already exist; nothing to download.")
+ return
+
+ data_path.mkdir(parents=True, exist_ok=True)
+ env = os.environ.copy()
+ env["OMNIGIBSON_DATA_PATH"] = str(data_path)
+ subprocess.run(
+ [
+ str(behavior_python),
+ "-c",
+ _DOWNLOAD_SNIPPET,
+ ",".join(actions),
+ "1" if accept_license else "0",
+ ],
+ check=True,
+ env=env,
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ try:
+ data_path = _require_data_path(args.data_path)
+ if args.verify:
+ verify_assets(
+ data_path=data_path,
+ checkpoint=_env_path(args.checkpoint, "PI05_CHECKPOINT_PATH"),
+ dino_source_archive=_env_path(
+ args.dino_source_archive, "DINOV2_SOURCE_ARCHIVE"
+ ),
+ dino_weights=_env_path(args.dino_weights, "DINOV2_WEIGHTS"),
+ )
+ else:
+ download_assets(
+ behavior_python=_behavior_python(args.behavior_python),
+ data_path=data_path,
+ accept_license=bool(args.accept_license),
+ skip_existing=bool(args.skip_existing),
+ )
+ except ValueError as error:
+ parser.error(str(error))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/robots/behavior/build_memory_cli.py b/robots/behavior/build_memory_cli.py
new file mode 100644
index 000000000..837e6c7dc
--- /dev/null
+++ b/robots/behavior/build_memory_cli.py
@@ -0,0 +1,83 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Build the BEHAVIOR DINO episode catalog from official demonstration data."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+from collections.abc import Sequence
+from pathlib import Path
+
+from robots.behavior.sft_offline_converter import compile_runtime_catalog
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="behavior-build-memory",
+ description=(
+ "Build the derived BEHAVIOR DINO episode-memory catalog from "
+ "official demonstration media and reviewed episode rollups."
+ ),
+ )
+ parser.add_argument("--selection-manifest", required=True, type=Path)
+ parser.add_argument("--output-dir", required=True, type=Path)
+ parser.add_argument("--video-root", required=True, type=Path, action="append")
+ parser.add_argument("--rollups-dir", required=True, type=Path)
+ parser.add_argument("--source-archive", required=True, type=Path)
+ parser.add_argument("--weights", required=True, type=Path)
+ parser.add_argument("--cache-dir", type=Path, default=None)
+ parser.add_argument(
+ "--cuda-device",
+ required=True,
+ help="Single CUDA device id made visible to the DINO compiler.",
+ )
+ parser.add_argument("--batch-size", type=int, default=32)
+ return parser
+
+
+def _single_cuda_device(value: str) -> str:
+ device = str(value).strip()
+ if not device or "," in device or not device.isdigit():
+ raise ValueError("--cuda-device must be one numeric device id")
+ return device
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ parser = _build_parser()
+ args = parser.parse_args(argv)
+ try:
+ os.environ["CUDA_VISIBLE_DEVICES"] = _single_cuda_device(args.cuda_device)
+ result = compile_runtime_catalog(
+ selection_manifest=args.selection_manifest.expanduser().resolve(),
+ output_dir=args.output_dir.expanduser().resolve(),
+ video_roots=tuple(path.expanduser().resolve() for path in args.video_root),
+ rollups_dir=args.rollups_dir.expanduser().resolve(),
+ source_archive=args.source_archive.expanduser().resolve(),
+ weights=args.weights.expanduser().resolve(),
+ cache_dir=None
+ if args.cache_dir is None
+ else args.cache_dir.expanduser().resolve(),
+ batch_size=int(args.batch_size),
+ )
+ except ValueError as error:
+ parser.error(str(error))
+ print(json.dumps(dict(result), sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/robots/behavior/dino_v2/__init__.py b/robots/behavior/dino_v2/__init__.py
new file mode 100644
index 000000000..4934bc53d
--- /dev/null
+++ b/robots/behavior/dino_v2/__init__.py
@@ -0,0 +1,35 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR DINOv2 encoder, RPC client, and server."""
+
+from robots.behavior.dino_v2.client import BehaviorDinoClient
+from robots.behavior.dino_v2.encoder import (
+ DINOV2_DIMENSION,
+ DISTANCE_METRIC,
+ Dinov2DeploymentPaths,
+ Dinov2Engine,
+ Dinov2RevisionIdentity,
+)
+from robots.behavior.dino_v2.server import BehaviorDinoFacade
+
+__all__ = [
+ "DINOV2_DIMENSION",
+ "DISTANCE_METRIC",
+ "BehaviorDinoFacade",
+ "BehaviorDinoClient",
+ "Dinov2DeploymentPaths",
+ "Dinov2Engine",
+ "Dinov2RevisionIdentity",
+]
diff --git a/robots/behavior/dino_v2/client.py b/robots/behavior/dino_v2/client.py
new file mode 100644
index 000000000..5f381d1d3
--- /dev/null
+++ b/robots/behavior/dino_v2/client.py
@@ -0,0 +1,87 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""RPC client for the optional BEHAVIOR DINOv2 component."""
+
+from __future__ import annotations
+
+import threading
+from collections.abc import Mapping
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.dino_v2.encoder import DINOV2_DIMENSION, l2_normalize_row
+from rpent.utils.rpc import RpcClient
+
+
+class BehaviorDinoClient:
+ """Small checked RPC wrapper around a DINOv2 encoder service."""
+
+ def __init__(
+ self,
+ client: RpcClient,
+ *,
+ expected_meta: dict[str, Any] | None = None,
+ ) -> None:
+ self._client = client
+ self._close_lock = threading.Lock()
+ self._transport_closed = False
+ meta = self.get_meta()
+ if expected_meta:
+ mismatches = {
+ key: {"expected": expected, "actual": meta.get(key)}
+ for key, expected in expected_meta.items()
+ if meta.get(key) != expected
+ }
+ if mismatches:
+ raise RuntimeError(f"dino_meta mismatch: {mismatches!r}")
+ self.server_meta = dict(meta)
+
+ def _call(self, method: str, **kwargs: Any) -> Any:
+ return self._client.call(method, kwargs=kwargs, timeout_s=120.0)
+
+ def get_meta(self) -> dict[str, Any]:
+ payload = self._client.call("dino.get_meta", timeout_s=5.0)
+ if not isinstance(payload, Mapping):
+ raise TypeError("dino.get_meta must return a mapping")
+ if payload.get("dimension") != DINOV2_DIMENSION:
+ raise RuntimeError("DINO service dimension does not match CLS384")
+ return dict(payload)
+
+ def encode_batch(
+ self, images: list[np.ndarray | None]
+ ) -> tuple[np.ndarray | None, ...]:
+ payload = self._call("dino.encode_batch", images=images)
+ if not isinstance(payload, list):
+ raise TypeError("dino.encode_batch must return a list")
+ result: list[np.ndarray | None] = []
+ for index, item in enumerate(payload):
+ if item is None:
+ result.append(None)
+ continue
+ result.append(l2_normalize_row(item, path=f"dino.output[{index}]"))
+ return tuple(result)
+
+ def close_transport(self) -> None:
+ with self._close_lock:
+ if self._transport_closed:
+ return
+ close = getattr(self._client, "close", None)
+ if callable(close):
+ close()
+ self._transport_closed = True
+
+
+__all__ = ["BehaviorDinoClient"]
diff --git a/robots/behavior/dino_v2/encoder.py b/robots/behavior/dino_v2/encoder.py
new file mode 100644
index 000000000..147bbdafd
--- /dev/null
+++ b/robots/behavior/dino_v2/encoder.py
@@ -0,0 +1,512 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Pinned DINOv2 ViT-S/14 RGB224 CLS384 embedding contract.
+
+The encoder identity is portable and path-free. Deployment paths are checked
+only when an actual backend is materialized. Tests and offline builders may
+inject a backend; the default backend imports torch lazily after verifying both
+frozen assets, so importing this module itself remains lightweight.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import importlib
+import os
+import tarfile
+import tempfile
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+from pathlib import Path, PurePosixPath
+from typing import Any, Protocol
+
+import numpy as np
+
+from robots.behavior.memory.schema import MemoryValidationError, fail, require_sha256
+
+MODEL_ID = "facebookresearch/dinov2_vits14"
+MODEL_REVISION = "facebookresearch/dinov2@7764ea0f912e53c92e82eb78a2a1631e92725fc8"
+EXPECTED_SOURCE_COMMIT = "7764ea0f912e53c92e82eb78a2a1631e92725fc8"
+EXPECTED_SOURCE_ARCHIVE_SHA256 = (
+ "c27dcdaf50e9fb5bbdf2bb529da357716372e19c6afab17d5350f3f0094aed4b"
+)
+EXPECTED_WEIGHTS_SHA256 = (
+ "b938bf1bc15cd2ec0feacfe3a1bb553fe8ea9ca46a7e1d8d00217f29aef60cd9"
+)
+PREPROCESS_ID = "rpent_dinov2_vits14_rgb224_bicubic_antialias_v1"
+EXTRACTOR_ID = "dinov2_vits14_cls_token_v1"
+DINOV2_DIMENSION = 384
+MAX_BATCH_SIZE = 32
+DISTANCE_METRIC = "one_minus_cosine_on_l2_cls384"
+
+
+class Dinov2Backend(Protocol):
+ torch_version: str
+ torchvision_version: str
+ device: str
+ eval_mode: bool
+ parameters_frozen: bool
+ inference_only: bool
+
+ def encode_batch(self, images: Sequence[np.ndarray]) -> np.ndarray: ...
+ def close(self) -> None: ...
+
+
+BackendLoader = Callable[
+ ["Dinov2RevisionIdentity", "Dinov2DeploymentPaths"], Dinov2Backend
+]
+
+
+@dataclass(frozen=True, slots=True)
+class Dinov2RevisionIdentity:
+ model_id: str
+ model_revision: str
+ source_commit: str
+ source_archive_sha256: str
+ weights_sha256: str
+ torch_version: str
+ torchvision_version: str
+ device: str
+ preprocess_id: str = PREPROCESS_ID
+ extractor_id: str = EXTRACTOR_ID
+ dimension: int = DINOV2_DIMENSION
+
+ def __post_init__(self) -> None:
+ expected = {
+ "model_id": MODEL_ID,
+ "model_revision": MODEL_REVISION,
+ "source_commit": EXPECTED_SOURCE_COMMIT,
+ "source_archive_sha256": EXPECTED_SOURCE_ARCHIVE_SHA256,
+ "weights_sha256": EXPECTED_WEIGHTS_SHA256,
+ "device": "cuda",
+ "preprocess_id": PREPROCESS_ID,
+ "extractor_id": EXTRACTOR_ID,
+ }
+ for field, value in expected.items():
+ if getattr(self, field) != value:
+ fail(
+ "MEMORY_DINOV2_IDENTITY_MISMATCH",
+ f"embedding.{field}",
+ f"expected {value!r}",
+ )
+ require_sha256(
+ self.source_archive_sha256, path="embedding.source_archive_sha256"
+ )
+ require_sha256(self.weights_sha256, path="embedding.weights_sha256")
+ if self.dimension != DINOV2_DIMENSION:
+ fail(
+ "MEMORY_DINOV2_DIMENSION_INVALID", "embedding.dimension", "expected 384"
+ )
+ for field in ("torch_version", "torchvision_version"):
+ value = getattr(self, field)
+ if not isinstance(value, str) or not value or value.strip() != value:
+ fail(
+ "MEMORY_DINOV2_IDENTITY_INVALID",
+ f"embedding.{field}",
+ "must be exact non-empty version",
+ )
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "model_id": self.model_id,
+ "model_revision": self.model_revision,
+ "source_commit": self.source_commit,
+ "source_archive_sha256": self.source_archive_sha256,
+ "weights_sha256": self.weights_sha256,
+ "torch_version": self.torch_version,
+ "torchvision_version": self.torchvision_version,
+ "device": self.device,
+ "preprocess_id": self.preprocess_id,
+ "extractor_id": self.extractor_id,
+ "dimension": self.dimension,
+ }
+
+
+@dataclass(frozen=True, slots=True)
+class Dinov2DeploymentPaths:
+ source_archive_path: Path
+ weights_path: Path
+ cache_dir: Path | None = None
+
+ def __post_init__(self) -> None:
+ for field in ("source_archive_path", "weights_path"):
+ value = getattr(self, field)
+ if not isinstance(value, Path) or not value.is_absolute():
+ fail("MEMORY_DINOV2_DEPLOYMENT_INVALID", field, "must be absolute Path")
+ if self.cache_dir is not None and (
+ not isinstance(self.cache_dir, Path) or not self.cache_dir.is_absolute()
+ ):
+ fail(
+ "MEMORY_DINOV2_DEPLOYMENT_INVALID",
+ "cache_dir",
+ "must be absolute Path or None",
+ )
+
+
+def l2_normalize_row(value: Any, *, path: str) -> np.ndarray:
+ row = np.asarray(value, dtype=np.float64)
+ if row.shape != (DINOV2_DIMENSION,) or not np.isfinite(row).all():
+ fail("MEMORY_DINOV2_VECTOR_INVALID", path, "expected finite vector[384]")
+ norm = float(np.linalg.norm(row))
+ if norm <= 0.0:
+ fail("MEMORY_DINOV2_VECTOR_INVALID", path, "cannot normalize zero vector")
+ result = np.asarray(row / norm, dtype=np.float32)
+ second = float(np.linalg.norm(result.astype(np.float64)))
+ if second <= 0.0:
+ fail("MEMORY_DINOV2_VECTOR_INVALID", path, "float32 normalization collapsed")
+ result = result / np.float32(second)
+ return np.ascontiguousarray(result, dtype=np.float32)
+
+
+def l2_matrix(values: Any, *, path: str) -> np.ndarray:
+ matrix = np.asarray(values, dtype=np.float32)
+ if (
+ matrix.ndim != 2
+ or matrix.shape[1] != DINOV2_DIMENSION
+ or not np.isfinite(matrix).all()
+ ):
+ fail("MEMORY_DINOV2_MATRIX_INVALID", path, "expected finite matrix[N,384]")
+ return (
+ np.stack(
+ [
+ l2_normalize_row(row, path=f"{path}[{index}]")
+ for index, row in enumerate(matrix)
+ ],
+ axis=0,
+ ).astype(np.float32, copy=False)
+ if matrix.shape[0]
+ else np.zeros((0, DINOV2_DIMENSION), dtype=np.float32)
+ )
+
+
+def _sha256_file(path: Path, *, label: str) -> str:
+ if not path.is_file():
+ fail("MEMORY_DINOV2_ASSET_MISSING", label, f"missing file: {path}")
+ digest = hashlib.sha256()
+ try:
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ except OSError as exc:
+ fail("MEMORY_DINOV2_ASSET_UNREADABLE", label, f"{type(exc).__name__}: {exc}")
+ return digest.hexdigest()
+
+
+def _safe_extract_source(source_archive: Path, destination: Path) -> Path:
+ try:
+ with tarfile.open(source_archive, mode="r:*") as archive:
+ members = archive.getmembers()
+ if not members:
+ fail(
+ "MEMORY_DINOV2_SOURCE_ARCHIVE_INVALID",
+ "source_archive",
+ "archive is empty",
+ )
+ for member in members:
+ portable = PurePosixPath(member.name)
+ if (
+ portable.is_absolute()
+ or ".." in portable.parts
+ or member.issym()
+ or member.islnk()
+ or not (member.isfile() or member.isdir())
+ ):
+ fail(
+ "MEMORY_DINOV2_SOURCE_ARCHIVE_INVALID",
+ "source_archive",
+ f"unsafe archive member {member.name!r}",
+ )
+ archive.extractall(destination)
+ except MemoryValidationError:
+ raise
+ except (OSError, tarfile.TarError) as exc:
+ fail(
+ "MEMORY_DINOV2_SOURCE_ARCHIVE_INVALID",
+ "source_archive",
+ f"{type(exc).__name__}: {exc}",
+ )
+ hubconf_paths = tuple(destination.rglob("hubconf.py"))
+ if len(hubconf_paths) != 1:
+ fail(
+ "MEMORY_DINOV2_SOURCE_ARCHIVE_INVALID",
+ "source_archive",
+ f"expected exactly one hubconf.py, found {len(hubconf_paths)}",
+ )
+ return hubconf_paths[0].parent
+
+
+class _TorchDinov2Backend:
+ def __init__(
+ self,
+ identity: Dinov2RevisionIdentity,
+ deployment: Dinov2DeploymentPaths,
+ ) -> None:
+ source_sha = _sha256_file(
+ deployment.source_archive_path, label="source_archive"
+ )
+ weights_sha = _sha256_file(deployment.weights_path, label="weights")
+ if source_sha != identity.source_archive_sha256:
+ fail(
+ "MEMORY_DINOV2_ASSET_SHA256_MISMATCH",
+ "source_archive",
+ f"expected {identity.source_archive_sha256}, actual {source_sha}",
+ )
+ if weights_sha != identity.weights_sha256:
+ fail(
+ "MEMORY_DINOV2_ASSET_SHA256_MISMATCH",
+ "weights",
+ f"expected {identity.weights_sha256}, actual {weights_sha}",
+ )
+
+ # Heavy imports remain after complete asset validation and after the
+ # service entry point has set CUDA_VISIBLE_DEVICES.
+ torch = importlib.import_module("torch")
+ torchvision = importlib.import_module("torchvision")
+ if str(torch.__version__) != identity.torch_version:
+ fail(
+ "MEMORY_DINOV2_BACKEND_IDENTITY_MISMATCH",
+ "torch_version",
+ f"expected {identity.torch_version!r}, actual {torch.__version__!r}",
+ )
+ if str(torchvision.__version__) != identity.torchvision_version:
+ fail(
+ "MEMORY_DINOV2_BACKEND_IDENTITY_MISMATCH",
+ "torchvision_version",
+ f"expected {identity.torchvision_version!r}, actual {torchvision.__version__!r}",
+ )
+ if identity.device != "cuda" or not torch.cuda.is_available():
+ fail(
+ "MEMORY_DINOV2_CUDA_UNAVAILABLE",
+ "device",
+ "the frozen encoder requires a visible CUDA device",
+ )
+
+ temporary_parent = deployment.cache_dir
+ if temporary_parent is None and Path("/dev/shm").is_dir():
+ temporary_parent = Path("/dev/shm")
+ if temporary_parent is not None:
+ try:
+ temporary_parent.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ fail(
+ "MEMORY_DINOV2_CACHE_INVALID",
+ "cache_dir",
+ f"{type(exc).__name__}: {exc}",
+ )
+ self._temporary = tempfile.TemporaryDirectory(
+ prefix="rpent-dinov2-source-",
+ dir=os.fspath(temporary_parent) if temporary_parent is not None else None,
+ )
+ source_root = _safe_extract_source(
+ deployment.source_archive_path,
+ Path(self._temporary.name),
+ )
+ try:
+ model = torch.hub.load(
+ os.fspath(source_root),
+ "dinov2_vits14",
+ source="local",
+ pretrained=False,
+ )
+ state = torch.load(
+ deployment.weights_path,
+ map_location="cpu",
+ weights_only=True,
+ )
+ model.load_state_dict(state, strict=True)
+ model.requires_grad_(False)
+ model.eval()
+ model.to(device="cuda")
+ except Exception as exc:
+ self._temporary.cleanup()
+ fail(
+ "MEMORY_DINOV2_MODEL_LOAD_FAILED",
+ "encoder.backend",
+ f"{type(exc).__name__}: {exc}",
+ )
+ if model.training or any(
+ parameter.requires_grad for parameter in model.parameters()
+ ):
+ self._temporary.cleanup()
+ fail(
+ "MEMORY_DINOV2_MODEL_NOT_FROZEN",
+ "encoder.backend",
+ "model must be eval-only and frozen",
+ )
+ self._torch = torch
+ self._functional = importlib.import_module("torchvision.transforms.functional")
+ transforms = importlib.import_module("torchvision.transforms")
+ self._bicubic = transforms.InterpolationMode.BICUBIC
+ self._model = model
+ self.torch_version = str(torch.__version__)
+ self.torchvision_version = str(torchvision.__version__)
+ self.device = "cuda"
+ self.eval_mode = True
+ self.parameters_frozen = True
+ self.inference_only = True
+
+ def _preprocess(self, image: np.ndarray) -> Any:
+ torch = self._torch
+ tensor = torch.from_numpy(image).permute(2, 0, 1)
+ height, width = image.shape[:2]
+ if height <= width:
+ resized_height = 256
+ resized_width = int(round(width * 256.0 / height))
+ else:
+ resized_width = 256
+ resized_height = int(round(height * 256.0 / width))
+ tensor = self._functional.resize(
+ tensor,
+ [resized_height, resized_width],
+ interpolation=self._bicubic,
+ antialias=True,
+ )
+ tensor = self._functional.center_crop(tensor, [224, 224])
+ tensor = tensor.to(dtype=torch.float32).div_(255.0)
+ return self._functional.normalize(
+ tensor,
+ mean=[0.485, 0.456, 0.406],
+ std=[0.229, 0.224, 0.225],
+ )
+
+ def encode_batch(self, images: Sequence[np.ndarray]) -> np.ndarray:
+ if self._model.training or any(
+ parameter.requires_grad for parameter in self._model.parameters()
+ ):
+ fail(
+ "MEMORY_DINOV2_MODEL_NOT_FROZEN",
+ "encoder.backend",
+ "model state changed after admission",
+ )
+ batch = self._torch.stack([self._preprocess(image) for image in images])
+ batch = batch.to(device="cuda", non_blocking=False)
+ with self._torch.inference_mode():
+ output = self._model(batch)
+ if not isinstance(output, self._torch.Tensor):
+ fail(
+ "MEMORY_DINOV2_OUTPUT_INVALID",
+ "encoder.output",
+ f"expected Tensor, got {type(output).__name__}",
+ )
+ return output.detach().to(device="cpu", dtype=self._torch.float32).numpy()
+
+ def close(self) -> None:
+ self._model = None
+ self._temporary.cleanup()
+
+
+def _default_backend_loader(
+ identity: Dinov2RevisionIdentity,
+ deployment: Dinov2DeploymentPaths,
+) -> Dinov2Backend:
+ return _TorchDinov2Backend(identity, deployment)
+
+
+class Dinov2Engine:
+ def __init__(
+ self,
+ identity: Dinov2RevisionIdentity,
+ deployment: Dinov2DeploymentPaths,
+ *,
+ backend_loader: BackendLoader | None = None,
+ ) -> None:
+ self._identity = identity
+ self._deployment = deployment
+ self._loader = backend_loader or _default_backend_loader
+ self._backend: Dinov2Backend | None = None
+ self._closed = False
+
+ def revision_metadata(self) -> dict[str, Any]:
+ return self._identity.as_dict()
+
+ def _backend_instance(self) -> Dinov2Backend:
+ if self._closed:
+ fail("MEMORY_DINOV2_ENCODER_CLOSED", "encoder", "encoder is closed")
+ if self._backend is None:
+ backend = self._loader(self._identity, self._deployment)
+ expected = {
+ "torch_version": self._identity.torch_version,
+ "torchvision_version": self._identity.torchvision_version,
+ "device": self._identity.device,
+ "eval_mode": True,
+ "parameters_frozen": True,
+ "inference_only": True,
+ }
+ for field, wanted in expected.items():
+ actual = getattr(backend, field, None)
+ if actual != wanted:
+ fail(
+ "MEMORY_DINOV2_BACKEND_IDENTITY_MISMATCH",
+ field,
+ f"expected {wanted!r}, actual {actual!r}",
+ )
+ self._backend = backend
+ return self._backend
+
+ def encode_batch(
+ self, values: Sequence[np.ndarray | None]
+ ) -> tuple[np.ndarray | None, ...]:
+ if len(values) > MAX_BATCH_SIZE:
+ fail(
+ "MEMORY_DINOV2_BATCH_TOO_LARGE",
+ "embedding_input",
+ "max batch size is 32",
+ )
+ result: list[np.ndarray | None] = [None] * len(values)
+ positions: list[int] = []
+ images: list[np.ndarray] = []
+ for index, value in enumerate(values):
+ if value is None:
+ continue
+ image = np.asarray(value)
+ if image.dtype != np.uint8 or image.ndim != 3 or image.shape[2] != 3:
+ fail(
+ "MEMORY_DINOV2_INPUT_INVALID",
+ f"embedding_input[{index}]",
+ "expected RGB8 [H,W,3]",
+ )
+ positions.append(index)
+ images.append(np.ascontiguousarray(image))
+ if not images:
+ if self._closed:
+ fail("MEMORY_DINOV2_ENCODER_CLOSED", "encoder", "encoder is closed")
+ return tuple(result)
+ raw = np.asarray(self._backend_instance().encode_batch(tuple(images)))
+ if raw.shape != (len(images), DINOV2_DIMENSION):
+ fail("MEMORY_DINOV2_OUTPUT_INVALID", "encoder.output", "expected [N,384]")
+ for row, position in enumerate(positions):
+ result[position] = l2_normalize_row(raw[row], path=f"encoder.output[{row}]")
+ return tuple(result)
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ backend, self._backend = self._backend, None
+ if backend is not None:
+ backend.close()
+
+
+__all__ = [
+ "DINOV2_DIMENSION",
+ "DISTANCE_METRIC",
+ "EXPECTED_SOURCE_ARCHIVE_SHA256",
+ "Dinov2DeploymentPaths",
+ "Dinov2Engine",
+ "Dinov2RevisionIdentity",
+ "MemoryValidationError",
+ "l2_matrix",
+ "l2_normalize_row",
+]
diff --git a/robots/behavior/dino_v2/server.py b/robots/behavior/dino_v2/server.py
new file mode 100644
index 000000000..09582bf25
--- /dev/null
+++ b/robots/behavior/dino_v2/server.py
@@ -0,0 +1,195 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""DINOv2 encoder RPC server for BEHAVIOR memory retrieval."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import re
+import sys
+import threading
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+# Support direct execution from an RPent checkout before package imports.
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
+
+from rpent.utils.rpc import RpcFacade # noqa: E402
+
+
+def _single_cuda_device(value: Any) -> str | None:
+ if value in (None, ""):
+ return None
+ device = str(value)
+ if re.fullmatch(r"[0-9]+", device) is None:
+ raise ValueError("--cuda-device must be one physical GPU ordinal")
+ return device
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _resolve_required_path(value: str | None, *, env_name: str, label: str) -> Path:
+ raw = value or os.environ.get(env_name)
+ if not raw:
+ raise RuntimeError(
+ f"DINO {label} path is required; set --{label.replace('_', '-')} "
+ f"or {env_name}"
+ )
+ path = Path(raw).expanduser().resolve()
+ if not path.is_file():
+ raise RuntimeError(f"DINO {label} path is missing: {path}")
+ return path
+
+
+class BehaviorDinoFacade(RpcFacade):
+ """Expose the BEHAVIOR DINOv2 engine through the shared RPC facade."""
+
+ def __init__(self, encoder: Any, meta: dict[str, Any]) -> None:
+ super().__init__()
+ self._encoder = encoder
+ self._meta = dict(meta)
+ self._close_lock = threading.Lock()
+ self._closed = False
+ self._register_rpc()
+
+ def _register_rpc(self) -> None:
+ self._rpc["dino.encode_batch"] = self.encode_batch
+ self._rpc["dino.get_meta"] = self.get_meta
+ self._readonly_methods.add("dino.get_meta")
+
+ def get_meta(self) -> dict[str, Any]:
+ return {**self._meta, "pid": os.getpid()}
+
+ def encode_batch(self, *, images: list[Any]) -> list[Any]:
+ result = self._encoder.encode_batch(
+ [
+ None if image is None else np.asarray(image, dtype=np.uint8)
+ for image in images
+ ]
+ )
+ return [
+ None if item is None else np.asarray(item, dtype=np.float32)
+ for item in result
+ ]
+
+ def close(self) -> None:
+ with self._close_lock:
+ if self._closed:
+ return
+ self._encoder.close()
+ self._closed = True
+
+
+def _materialize_encoder(args: argparse.Namespace) -> tuple[Any, dict[str, Any]]:
+ # Heavy imports begin only after main() has applied CUDA_VISIBLE_DEVICES.
+ import torch
+ import torchvision
+
+ from robots.behavior.dino_v2.encoder import (
+ DINOV2_DIMENSION,
+ MODEL_ID,
+ MODEL_REVISION,
+ Dinov2DeploymentPaths,
+ Dinov2Engine,
+ Dinov2RevisionIdentity,
+ )
+
+ source_archive = _resolve_required_path(
+ args.source_archive,
+ env_name="RPENT_BEHAVIOR_DINOV2_SOURCE_ARCHIVE",
+ label="source_archive",
+ )
+ weights = _resolve_required_path(
+ args.weights,
+ env_name="RPENT_BEHAVIOR_DINOV2_WEIGHTS",
+ label="weights",
+ )
+ device = "cuda" if torch.cuda.is_available() else "cpu"
+ if device != "cuda":
+ raise RuntimeError(
+ "DINO service requires CUDA; CPU fallback is not a BEHAVIOR runtime component"
+ )
+ identity = Dinov2RevisionIdentity(
+ model_id=MODEL_ID,
+ model_revision=MODEL_REVISION,
+ source_commit=MODEL_REVISION.rsplit("@", 1)[-1],
+ source_archive_sha256=_sha256_file(source_archive),
+ weights_sha256=_sha256_file(weights),
+ torch_version=str(torch.__version__),
+ torchvision_version=str(torchvision.__version__),
+ device=device,
+ )
+ deployment = Dinov2DeploymentPaths(
+ source_archive_path=source_archive,
+ weights_path=weights,
+ cache_dir=Path(args.cache_dir).expanduser().resolve()
+ if args.cache_dir
+ else None,
+ )
+ encoder = Dinov2Engine(identity, deployment)
+ # Force backend construction before get_meta advertises the deployment.
+ blank = np.zeros((224, 224, 3), dtype=np.uint8)
+ encoder.encode_batch([blank])
+ return encoder, {
+ "status": "ok",
+ "runtime": "behavior_dino",
+ "model_id": MODEL_ID,
+ "model_revision": MODEL_REVISION,
+ "dimension": DINOV2_DIMENSION,
+ "device": device,
+ "checkpoint_binding": identity.as_dict(),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, required=True)
+ parser.add_argument("--cuda-device", default=None)
+ parser.add_argument("--source-archive", default=None)
+ parser.add_argument("--weights", default=None)
+ parser.add_argument("--cache-dir", default=None)
+ parser.add_argument("--parent-watch", action="store_true")
+ args = parser.parse_args()
+ cuda_device = _single_cuda_device(args.cuda_device)
+ if cuda_device is not None:
+ os.environ["CUDA_VISIBLE_DEVICES"] = cuda_device
+
+ encoder, meta = _materialize_encoder(args)
+ facade = BehaviorDinoFacade(encoder, meta)
+ facade.serve(
+ transport="http",
+ host=args.host,
+ port=args.port,
+ parent_watch=args.parent_watch,
+ )
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = ["BehaviorDinoFacade", "main"]
diff --git a/robots/behavior/env_client.py b/robots/behavior/env_client.py
new file mode 100644
index 000000000..5c32c161c
--- /dev/null
+++ b/robots/behavior/env_client.py
@@ -0,0 +1,332 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""RPC client for one BEHAVIOR environment."""
+
+from __future__ import annotations
+
+import base64
+import copy
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.schemas import (
+ ACTION_DIM,
+ validate_action_chunk,
+ validate_move_both_targets,
+ validate_move_both_visual_hand_checks,
+ validate_observe_request,
+ validate_relative_navigation_motion,
+)
+from robots.behavior.terminal_success import validate_official_success_receipt
+from rpent.robots.components.env_client_base import BaseEnvClient
+from rpent.utils.rpc import RpcClient
+
+_POST_SUCCESS_ALLOWED = frozenset(
+ {
+ "env.get_env_meta",
+ "env.current_observation",
+ }
+)
+_IMAGE_BYTE_FIELDS = frozenset(
+ {
+ "_depth_image_bytes",
+ "_image_bytes",
+ "_image_left_wrist_bytes",
+ "_depth_left_wrist_bytes",
+ "_image_right_wrist_bytes",
+ "_depth_right_wrist_bytes",
+ }
+)
+
+
+def _info_from_rpc_result(ret: Any) -> Any:
+ if isinstance(ret, (tuple, list)):
+ if len(ret) == 5:
+ return ret[4]
+ if len(ret) == 2:
+ return ret[1]
+ if isinstance(ret, dict):
+ return ret.get("info", ret)
+ return None
+
+
+def _decode_observe_images(result: Any) -> Any:
+ """Decode only public image fields returned by ``env.observe``."""
+
+ if not isinstance(result, dict):
+ return result
+ decoded = dict(result)
+ for field in _IMAGE_BYTE_FIELDS:
+ payload = decoded.get(field)
+ if (
+ isinstance(payload, dict)
+ and set(payload) == {"encoding", "data"}
+ and payload.get("encoding") == "base64"
+ and isinstance(payload.get("data"), str)
+ ):
+ decoded[field] = base64.b64decode(payload["data"], validate=True)
+ return decoded
+
+
+class BehaviorEnvClient(BaseEnvClient):
+ """Remote implementation of the BEHAVIOR single-env protocol.
+
+ Construction verifies only the requested immutable metadata and deliberately
+ does not reset the simulator. Runtime initialization owns the first reset.
+ """
+
+ _TIMEOUT_S = {
+ **BaseEnvClient._TIMEOUT_S,
+ "env.reset": 300.0,
+ "env.step": 60.0,
+ "env.chunk_step": 600.0,
+ "env.current_observation": 120.0,
+ "env.observe": 120.0,
+ "env.pixel_to_world": 120.0,
+ "env.move_to": 600.0,
+ "env.navigate_to": 600.0,
+ "env.rotate_wrist": 600.0,
+ "env.close_gripper": 120.0,
+ "env.open_gripper": 120.0,
+ "env.press": 300.0,
+ }
+
+ def __init__(self, client: RpcClient, *, expected_meta: dict[str, Any]) -> None:
+ # BaseEnvClient.__init__ performs an automatic reset and exact metadata
+ # equality. BEHAVIOR reset is explicitly owned by runtime initialization.
+ self._client = client
+ self.last_obs: dict[str, Any] | None = None
+ self.last_info: dict[str, Any] = {}
+ self.episode_done = False
+ self.total_env_steps = 0
+ self.vla_endpoint: str | None = None
+ self._official_success_latched = False
+ self._official_success_receipt: dict[str, Any] | None = None
+ server_meta = self._client.call(
+ "env.get_env_meta",
+ timeout_s=self._TIMEOUT_S["default"],
+ )
+ if not isinstance(server_meta, dict):
+ raise RuntimeError(f"env_meta must be a mapping, got {type(server_meta)!r}")
+ mismatches = {
+ key: {"expected": expected, "actual": server_meta.get(key)}
+ for key, expected in expected_meta.items()
+ if server_meta.get(key) != expected
+ }
+ if mismatches:
+ raise RuntimeError(f"env_meta mismatch: {mismatches!r}")
+ self.server_meta = dict(server_meta)
+
+ @staticmethod
+ def _raw_success(info: Any) -> bool:
+ done = info.get("done") if isinstance(info, dict) else None
+ value = done.get("success") if isinstance(done, dict) else None
+ return isinstance(value, (bool, np.bool_)) and bool(value)
+
+ @staticmethod
+ def _receipt_from_info(info: Any) -> dict[str, Any] | None:
+ runtime = info.get("_rpent") if isinstance(info, dict) else None
+ if not isinstance(runtime, dict):
+ return None
+ receipt = runtime.get("official_success_receipt")
+ return copy.deepcopy(receipt) if isinstance(receipt, dict) else None
+
+ def _latch_success_response(self, ret: Any) -> None:
+ info = _info_from_rpc_result(ret)
+ if not isinstance(info, dict):
+ return
+ runtime = info.get("_rpent")
+ if isinstance(runtime, dict):
+ steps = runtime.get("total_env_steps", runtime.get("global_env_steps"))
+ if isinstance(steps, (int, np.integer)) and not isinstance(steps, bool):
+ self.total_env_steps = max(self.total_env_steps, int(steps))
+ if self._raw_success(info):
+ self.episode_done = True
+ self._official_success_latched = True
+ self._official_success_receipt = validate_official_success_receipt(
+ self._receipt_from_info(info)
+ )
+
+ def _rpc_call(
+ self,
+ method: str,
+ *,
+ args: tuple = (),
+ kwargs: dict[str, Any] | None = None,
+ timeout_s: float | None = None,
+ ) -> Any:
+ if self._official_success_latched and method not in _POST_SUCCESS_ALLOWED:
+ raise RuntimeError(
+ "raw task success is terminal; no further RPC is allowed"
+ )
+ ret = self._client.call(
+ method,
+ args=args,
+ kwargs=kwargs or {},
+ timeout_s=(
+ timeout_s
+ if timeout_s is not None
+ else self._TIMEOUT_S.get(method, self._TIMEOUT_S["default"])
+ ),
+ )
+ self._latch_success_response(ret)
+ return ret
+
+ @property
+ def official_success_latched(self) -> bool:
+ return self._official_success_latched
+
+ @property
+ def official_success_receipt(self) -> dict[str, Any] | None:
+ return copy.deepcopy(self._official_success_receipt)
+
+ def reset(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ ret = self._rpc_call("env.reset", timeout_s=self._TIMEOUT_S["env.reset"])
+ if not isinstance(ret, (tuple, list)) or len(ret) != 2:
+ raise TypeError("env.reset must return (observation, info)")
+ obs, info = ret
+ if not isinstance(obs, dict) or not isinstance(info, dict):
+ raise TypeError("env.reset must return observation/info mappings")
+ self.total_env_steps = 0
+ self.episode_done = False
+ self.last_obs = obs
+ self.last_info = info
+ return obs, info
+
+ def step(self, action: Any) -> tuple[Any, Any, Any, Any, dict[str, Any]]:
+ array = np.asarray(action, dtype=np.float32)
+ if array.shape != (ACTION_DIM,) or not np.isfinite(array).all():
+ raise ValueError(f"BEHAVIOR action must be finite [{ACTION_DIM}]")
+ ret = self._rpc_call("env.step", args=(np.ascontiguousarray(array),))
+ return self._note_gym_result(ret, "env.step")
+
+ def chunk_step(
+ self,
+ actions: Any,
+ *,
+ return_all_frames: bool = False,
+ ) -> tuple[Any, Any, Any, Any, dict[str, Any]]:
+ action_array = validate_action_chunk(actions)
+ ret = self._rpc_call(
+ "env.chunk_step",
+ args=(action_array,),
+ kwargs={"return_all_frames": bool(return_all_frames)},
+ )
+ return self._note_gym_result(ret, "env.chunk_step")
+
+ def _note_gym_result(self, ret: Any, method: str) -> tuple:
+ if not isinstance(ret, (tuple, list)) or len(ret) != 5:
+ raise TypeError(f"{method} must return a gym 5-tuple")
+ obs, _reward, _terminated, _truncated, info = ret
+ if not isinstance(info, dict):
+ raise TypeError(f"{method} info must be a mapping")
+ if isinstance(obs, list):
+ if not obs:
+ raise ValueError(f"{method} returned an empty observation list")
+ self.last_obs = obs[-1]
+ elif isinstance(obs, dict):
+ self.last_obs = obs
+ else:
+ raise TypeError(f"{method} observation must be a mapping or list")
+ self.last_info = info
+ return tuple(ret)
+
+ def current_observation(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ ret = self._rpc_call("env.current_observation")
+ if not isinstance(ret, (tuple, list)) or len(ret) != 2:
+ raise TypeError("env.current_observation must return (observation, info)")
+ obs, info = ret
+ if not isinstance(obs, dict) or not isinstance(info, dict):
+ raise TypeError("env.current_observation returned invalid payload")
+ self.last_obs = obs
+ self.last_info = info
+ return obs, info
+
+ def get_camera_meta(self, camera_name: str, **kwargs: Any) -> dict[str, Any]:
+ value = self._rpc_call(
+ "env.get_camera_meta",
+ kwargs={"camera_name": camera_name, **kwargs},
+ )
+ if not isinstance(value, dict):
+ raise TypeError("env.get_camera_meta must return a mapping")
+ return value
+
+ def render_camera(self, camera_name: str, **kwargs: Any) -> np.ndarray:
+ value = self._rpc_call(
+ "env.render_camera",
+ kwargs={"camera_name": camera_name, **kwargs},
+ )
+ image = np.asarray(value)
+ if image.dtype != np.uint8 or image.ndim != 3 or image.shape[-1] != 3:
+ raise TypeError("env.render_camera must return uint8[H,W,3]")
+ return image
+
+ def get_task_language(self) -> str:
+ value = self._rpc_call("env.get_task_language")
+ if not isinstance(value, str):
+ raise TypeError("env.get_task_language must return a string")
+ return value
+
+ def observe(self, **kwargs: Any) -> dict[str, Any]:
+ request = validate_observe_request(**kwargs)
+ result = _decode_observe_images(self._rpc_call("env.observe", kwargs=request))
+ if not isinstance(result, dict):
+ raise TypeError("env.observe must return a mapping")
+ return result
+
+ def pixel_to_world(self, **kwargs: Any) -> dict[str, Any]:
+ return self._rpc_call("env.pixel_to_world", kwargs=kwargs)
+
+ def navigate_to(self, **kwargs: Any) -> dict[str, Any]:
+ if "relative_motion" in kwargs and kwargs["relative_motion"] is not None:
+ kwargs = {
+ **kwargs,
+ "relative_motion": validate_relative_navigation_motion(
+ kwargs["relative_motion"]
+ ),
+ }
+ return self._rpc_call("env.navigate_to", kwargs=kwargs)
+
+ def move_to(self, **kwargs: Any) -> dict[str, Any]:
+ if kwargs.get("hand") == "both":
+ kwargs = {
+ **kwargs,
+ "targets": validate_move_both_targets(kwargs.get("targets")),
+ "visual_hand_checks": validate_move_both_visual_hand_checks(
+ kwargs.get("visual_hand_checks")
+ ),
+ }
+ return self._rpc_call("env.move_to", kwargs=kwargs)
+
+ def rotate_wrist(self, **kwargs: Any) -> dict[str, Any]:
+ return self._rpc_call("env.rotate_wrist", kwargs=kwargs)
+
+ def close_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ return self._rpc_call("env.close_gripper", kwargs=kwargs)
+
+ def open_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ return self._rpc_call("env.open_gripper", kwargs=kwargs)
+
+ def press(self, **kwargs: Any) -> dict[str, Any]:
+ return self._rpc_call("env.press", kwargs=kwargs)
+
+ def close_transport(self) -> None:
+ close = getattr(self._client, "close", None)
+ if callable(close):
+ close()
+
+
+__all__ = ["BehaviorEnvClient"]
diff --git a/robots/behavior/env_server.py b/robots/behavior/env_server.py
new file mode 100644
index 000000000..1e5142bb1
--- /dev/null
+++ b/robots/behavior/env_server.py
@@ -0,0 +1,320 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR environment RPC adapter.
+
+OmniGibson scene operations stay on the process main thread. The facade uses
+the common main-thread RPC serve mixin so transport handling can run on a
+daemon thread while BEHAVIOR dispatch remains serialized on the calling thread.
+"""
+
+from __future__ import annotations
+
+import argparse
+import base64
+import os
+import re
+import signal
+import sys
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+# Support direct execution from an RPent checkout before package imports.
+if __package__ in (None, ""):
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
+
+from robots.behavior.schemas import ( # noqa: E402
+ ACTION_DIM,
+ DEFAULT_ACTION_CHUNK,
+ validate_action_chunk,
+)
+from robots.behavior.task_specs import get_task_spec # noqa: E402
+from rpent.robots.components.env_facade_base import BaseEnvFacade # noqa: E402
+from rpent.utils.rpc.main_thread_serve import MainThreadServeMixin # noqa: E402
+
+_IMAGE_BYTE_FIELDS = frozenset(
+ {
+ "_depth_image_bytes",
+ "_image_bytes",
+ "_image_left_wrist_bytes",
+ "_depth_left_wrist_bytes",
+ "_image_right_wrist_bytes",
+ "_depth_right_wrist_bytes",
+ }
+)
+
+
+def _single_cuda_device(value: Any) -> str | None:
+ if value in (None, ""):
+ return None
+ device = str(value)
+ if re.fullmatch(r"[0-9]+", device) is None:
+ raise ValueError("--cuda-device must be one physical GPU ordinal")
+ return device
+
+
+def _encode_observe_images(result: Any) -> Any:
+ """Encode only public image byte fields at the ``env.observe`` boundary."""
+
+ if not isinstance(result, dict):
+ return result
+ encoded = dict(result)
+ for field in _IMAGE_BYTE_FIELDS:
+ payload = encoded.get(field)
+ if isinstance(payload, bytes):
+ encoded[field] = {
+ "encoding": "base64",
+ "data": base64.b64encode(payload).decode("ascii"),
+ }
+ return encoded
+
+
+class BehaviorEnvFacade(MainThreadServeMixin, BaseEnvFacade):
+ """Expose one official RLinf BEHAVIOR backend through common ENV RPC."""
+
+ def __init__(self, *, backend: Any, meta: dict[str, Any]) -> None:
+ self._backend = backend
+ self._meta = dict(meta)
+ self._closed = False
+ super().__init__()
+
+ def _register_rpc(self) -> None:
+ super()._register_rpc()
+ self._rpc.update(
+ {
+ "env.current_observation": self.current_observation,
+ "env.observe": self.observe,
+ "env.pixel_to_world": self.pixel_to_world,
+ "env.navigate_to": self.navigate_to,
+ "env.move_to": self.move_to,
+ "env.rotate_wrist": self.rotate_wrist,
+ "env.close_gripper": self.close_gripper,
+ "env.open_gripper": self.open_gripper,
+ "env.press": self.press,
+ }
+ )
+ self._readonly_methods.update(
+ {
+ "env.current_observation",
+ }
+ )
+
+ def _call_backend(self, name: str, *args: Any, **kwargs: Any) -> Any:
+ method = getattr(self._backend, name, None)
+ if not callable(method):
+ raise RuntimeError(f"backend does not expose {name}()")
+ return method(*args, **kwargs)
+
+ @property
+ def total_env_steps(self) -> int:
+ value = getattr(self._backend, "total_env_steps", 0)
+ if isinstance(value, (int, np.integer)) and not isinstance(
+ value, (bool, np.bool_)
+ ):
+ return max(0, int(value))
+ return 0
+
+ def get_env_meta(self) -> dict[str, Any]:
+ return dict(self._meta)
+
+ def reset(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ result = self._call_backend("reset")
+ if not isinstance(result, (tuple, list)) or len(result) != 2:
+ raise TypeError("backend reset must return (observation, info)")
+ observation, info = result
+ if not isinstance(observation, dict) or not isinstance(info, dict):
+ raise TypeError("backend reset must return observation/info mappings")
+ observation.setdefault("task_descriptions", self._meta["task_language"])
+ return observation, info
+
+ def step(self, action: Any) -> tuple[Any, Any, Any, Any, dict[str, Any]]:
+ result = self._call_backend("step", action)
+ return self._require_gym_result(result, "step")
+
+ def chunk_step(
+ self,
+ actions: Any,
+ *,
+ return_all_frames: bool = False,
+ ) -> tuple[Any, Any, Any, Any, dict[str, Any]]:
+ action_array = validate_action_chunk(actions)
+ result = self._call_backend(
+ "chunk_step",
+ action_array,
+ return_all_frames=bool(return_all_frames),
+ )
+ return self._require_gym_result(result, "chunk_step")
+
+ @staticmethod
+ def _require_gym_result(result: Any, method: str) -> tuple:
+ if not isinstance(result, (tuple, list)) or len(result) != 5:
+ raise TypeError(f"env.{method} must return a gym 5-tuple")
+ if not isinstance(result[4], dict):
+ raise TypeError(f"env.{method} info must be a mapping")
+ return tuple(result)
+
+ def current_observation(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ result = self._call_backend("current_observation")
+ if not isinstance(result, (tuple, list)) or len(result) != 2:
+ raise TypeError("current_observation must return (observation, info)")
+ observation, info = result
+ if not isinstance(observation, dict) or not isinstance(info, dict):
+ raise TypeError("current_observation returned invalid payload")
+ return observation, info
+
+ def get_task_language(self) -> str:
+ value = self._call_backend("get_task_language")
+ if not isinstance(value, str):
+ raise TypeError("BEHAVIOR task language must be a string")
+ return value
+
+ def get_camera_meta(self, camera_name: str = "head", **kwargs: Any) -> dict:
+ value = self._call_backend("get_camera_meta", camera_name, **kwargs)
+ if not isinstance(value, dict):
+ raise TypeError("BEHAVIOR camera metadata must be a mapping")
+ return value
+
+ def render_camera(self, camera_name: str = "head", **kwargs: Any) -> np.ndarray:
+ image = np.asarray(self._call_backend("render_camera", camera_name, **kwargs))
+ if image.dtype != np.uint8 or image.ndim != 3 or image.shape[-1] != 3:
+ raise ValueError(
+ f"rendered RGB must be uint8[H,W,3], got {image.dtype}{image.shape}"
+ )
+ return np.ascontiguousarray(image)
+
+ def observe(self, **kwargs: Any) -> dict[str, Any]:
+ result = self._call_backend("observe", **kwargs)
+ if not isinstance(result, dict):
+ raise TypeError("env.observe must return a mapping")
+ return _encode_observe_images(result)
+
+ def pixel_to_world(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("pixel_to_world", **kwargs)
+
+ def navigate_to(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("navigate_to", **kwargs)
+
+ def move_to(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("move_to", **kwargs)
+
+ def rotate_wrist(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("rotate_wrist", **kwargs)
+
+ def close_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ if not kwargs:
+ raise ValueError(
+ "env.close_gripper requires primitive arguments; "
+ "lifecycle shutdown uses BehaviorEnvFacade.close()"
+ )
+ return self._call_backend("close", **kwargs)
+
+ def open_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("open", **kwargs)
+
+ def press(self, **kwargs: Any) -> dict[str, Any]:
+ return self._call_backend("press", **kwargs)
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ closer = getattr(self._backend, "close", None)
+ if callable(closer):
+ closer()
+ self._closed = True
+
+
+def _build_meta(args: argparse.Namespace) -> dict[str, Any]:
+ task_spec = get_task_spec(args.task_name)
+ return {
+ "runtime": "behavior_env",
+ "task_name": task_spec.task_name,
+ "task": int(args.task_index),
+ "task_language": task_spec.task_language,
+ "activity_definition_id": int(args.activity_definition_id),
+ "activity_instance_id": int(args.activity_instance_id),
+ "public_seed": int(args.public_seed),
+ "scene_model": str(args.scene_model),
+ "max_episode_steps": int(args.max_episode_steps),
+ "action_dim": ACTION_DIM,
+ "action_horizon": DEFAULT_ACTION_CHUNK,
+ "official_success_path": ["info", "done", "success"],
+ "behavior_repo": str(Path(args.behavior_repo).expanduser().resolve()),
+ "activity_instance_dir": (
+ None
+ if not args.activity_instance_dir
+ else str(Path(args.activity_instance_dir).expanduser().resolve())
+ ),
+ "rlinf_env_config_path": (
+ None
+ if not args.env_config_path
+ else str(Path(args.env_config_path).expanduser().resolve())
+ ),
+ }
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--task-name", required=True)
+ parser.add_argument("--public-seed", type=int, required=True)
+ parser.add_argument("--task-index", type=int, required=True)
+ parser.add_argument("--activity-definition-id", type=int, required=True)
+ parser.add_argument("--activity-instance-id", type=int, required=True)
+ parser.add_argument("--scene-model", required=True)
+ parser.add_argument("--max-episode-steps", type=int, required=True)
+ parser.add_argument("--output-dir", required=True)
+ parser.add_argument("--behavior-repo", required=True)
+ parser.add_argument("--activity-instance-dir", default=None)
+ parser.add_argument("--env-config-path", default=None)
+ parser.add_argument("--host", default="127.0.0.1")
+ parser.add_argument("--port", type=int, required=True)
+ parser.add_argument("--cuda-device", default=None)
+ parser.add_argument("--parent-watch", action="store_true")
+ args = parser.parse_args()
+ cuda_device = _single_cuda_device(args.cuda_device)
+ if cuda_device is not None:
+ os.environ["CUDA_VISIBLE_DEVICES"] = cuda_device
+ os.environ["RPENT_RLINF_ROOT"] = str(
+ Path(args.behavior_repo).expanduser().resolve()
+ )
+
+ output_dir = Path(args.output_dir).expanduser().resolve()
+ output_dir.mkdir(parents=True, exist_ok=True)
+ from robots.behavior.rlinf_env import OfficialBehaviorBackend
+
+ meta = _build_meta(args)
+ backend = OfficialBehaviorBackend(meta=meta, output_dir=output_dir)
+ facade = BehaviorEnvFacade(backend=backend, meta=meta)
+ # ProcessDaemon.stop sends SIGTERM. Let the serving loop finish and close
+ # the RLinf actor pool on its owning thread, not Ray's exit handler.
+ previous_sigterm = signal.signal(
+ signal.SIGTERM, lambda signum, frame: facade._shutdown_event.set()
+ )
+ try:
+ facade.serve(
+ transport="http",
+ host=args.host,
+ port=args.port,
+ parent_watch=args.parent_watch,
+ )
+ finally:
+ signal.signal(signal.SIGTERM, previous_sigterm)
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = ["BehaviorEnvFacade", "main"]
diff --git a/robots/behavior/install_behavior_runtime.sh b/robots/behavior/install_behavior_runtime.sh
new file mode 100644
index 000000000..3b396ea84
--- /dev/null
+++ b/robots/behavior/install_behavior_runtime.sh
@@ -0,0 +1,252 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+RPENT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
+REPRO_ROOT="${RPENT_REPRO_ROOT:-${RPENT_ROOT}/.behavior-runtime}"
+RLINF_ROOT="${RLINF_ROOT:-${REPRO_ROOT}/RLinf}"
+RPENT_VENV="${RPENT_VENV:-${REPRO_ROOT}/venvs/rpent}"
+BEHAVIOR_VENV="${BEHAVIOR_VENV:-${REPRO_ROOT}/venvs/behavior}"
+LOG_DIR="${LOG_DIR:-${REPRO_ROOT}/logs/install}"
+TOOLS_DIR="${TOOLS_DIR:-${REPRO_ROOT}/tools}"
+export DOWNLOAD_DIR="${DOWNLOAD_DIR:-${REPRO_ROOT}/downloads}"
+UV_VERSION="${UV_VERSION:-0.12.7}"
+PYTHON_VERSION="${PYTHON_VERSION:-3.10}"
+RLINF_REPO_URL="${RLINF_REPO_URL:-https://github.com/RLinf/RLinf.git}"
+RLINF_COMMIT="${RLINF_COMMIT:-dd92c62857da4c67aa5e7c36f731c0d6a121f6d7}"
+
+mkdir -p "${LOG_DIR}" "${TOOLS_DIR}" "${DOWNLOAD_DIR}" "$(dirname "${RPENT_VENV}")"
+LOG_FILE="${LOG_DIR}/install-$(date -u +%Y%m%dT%H%M%SZ).log"
+exec > >(tee -a "${LOG_FILE}") 2>&1
+
+trap 'echo "ERROR: command failed at line ${LINENO}. See ${LOG_FILE}" >&2' ERR
+
+require_command() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ echo "Missing required host command: $1" >&2
+ exit 1
+ fi
+}
+
+for command_name in bash curl git nvidia-smi sha256sum; do
+ require_command "${command_name}"
+done
+
+echo "RPent root: ${RPENT_ROOT}"
+echo "RLinf root: ${RLINF_ROOT}"
+echo "RPent venv: ${RPENT_VENV}"
+echo "BEHAVIOR venv: ${BEHAVIOR_VENV}"
+echo "Install log: ${LOG_FILE}"
+
+UV_BIN="${TOOLS_DIR}/uv"
+UV_VERSION_OUTPUT="$("${UV_BIN}" --version 2>/dev/null || true)"
+if [[ ! -x "${UV_BIN}" ]] || {
+ [[ "${UV_VERSION_OUTPUT}" != "uv ${UV_VERSION}" ]] &&
+ [[ "${UV_VERSION_OUTPUT}" != "uv ${UV_VERSION} "* ]]
+}; then
+ UV_INSTALLER="${TOOLS_DIR}/uv-install-${UV_VERSION}.sh"
+ curl -LsSf "https://astral.sh/uv/${UV_VERSION}/install.sh" -o "${UV_INSTALLER}"
+ env UV_UNMANAGED_INSTALL="${TOOLS_DIR}" sh "${UV_INSTALLER}"
+fi
+UV_VERSION_OUTPUT="$("${UV_BIN}" --version)"
+if [[ "${UV_VERSION_OUTPUT}" != "uv ${UV_VERSION}" ]] &&
+ [[ "${UV_VERSION_OUTPUT}" != "uv ${UV_VERSION} "* ]]; then
+ echo "Expected uv ${UV_VERSION}, got ${UV_VERSION_OUTPUT}." >&2
+ exit 1
+fi
+export PATH="${TOOLS_DIR}:${PATH}"
+
+if [[ ! -d "${RLINF_ROOT}/.git" ]]; then
+ mkdir -p "$(dirname "${RLINF_ROOT}")"
+ git clone "${RLINF_REPO_URL}" "${RLINF_ROOT}"
+ git -C "${RLINF_ROOT}" checkout --detach "${RLINF_COMMIT}"
+else
+ ACTUAL_RLINF_COMMIT="$(git -C "${RLINF_ROOT}" rev-parse HEAD)"
+ if [[ "${ACTUAL_RLINF_COMMIT}" != "${RLINF_COMMIT}" ]]; then
+ echo "Existing RLinf checkout is ${ACTUAL_RLINF_COMMIT}, expected ${RLINF_COMMIT}." >&2
+ echo "Use a new RLINF_ROOT; this script will not overwrite a checkout." >&2
+ exit 1
+ fi
+ if [[ -n "$(git -C "${RLINF_ROOT}" status --porcelain)" ]]; then
+ echo "Existing RLinf checkout is dirty; refusing to run its installer." >&2
+ exit 1
+ fi
+fi
+
+if [[ ! -x "${RPENT_VENV}/bin/python" ]]; then
+ "${UV_BIN}" venv --python "${PYTHON_VERSION}" "${RPENT_VENV}"
+fi
+"${UV_BIN}" pip install --python "${RPENT_VENV}/bin/python" -e "${RPENT_ROOT}"
+# Episode video writer backend; mirrors the .[behavior] extra pin.
+# Do NOT switch the rpent venv to .[behavior] (keeps the CLI venv light).
+"${UV_BIN}" pip install --python "${RPENT_VENV}/bin/python" "imageio-ffmpeg>=0.5"
+
+export UV_TORCH_BACKEND=cu124
+(
+ cd "${RLINF_ROOT}"
+ bash requirements/install.sh embodied \
+ --model openpi \
+ --env behavior \
+ --venv "${BEHAVIOR_VENV}" \
+ --install-rlinf \
+ --no-flash-attn \
+ --no-root
+)
+
+BEHAVIOR_PYTHON="${BEHAVIOR_VENV}/bin/python"
+if [[ ! -x "${BEHAVIOR_PYTHON}" ]]; then
+ echo "RLinf installer did not create ${BEHAVIOR_PYTHON}." >&2
+ exit 1
+fi
+
+# Install RPent and all HTTP sidecar dependencies before the final compatibility pins.
+# Planner packages in this environment are metadata-only for the sidecars; the
+# separate RPent venv remains the canonical planner and Dashboard environment.
+"${UV_BIN}" pip install --python "${BEHAVIOR_PYTHON}" -e "${RPENT_ROOT}"
+
+# The official BEHAVIOR runtime is validated against CUDA 12.4 and torch 2.5.1.
+"${UV_BIN}" pip install --python "${BEHAVIOR_PYTHON}" --reinstall \
+ --index-url https://download.pytorch.org/whl/cu124 \
+ 'torch==2.5.1+cu124' \
+ 'torchvision==0.20.1+cu124' \
+ 'torchaudio==2.5.1+cu124'
+
+# Shared constraints protect the simulation stack during motion-planner installation.
+FINAL_PINS=(
+ 'numpy==1.26.4'
+ 'protobuf==6.33.0'
+ 'ml-dtypes==0.5.3'
+ 'click==8.2.1'
+ 'llvmlite==0.48.0'
+ 'numba==0.66.0'
+ 'fastapi==0.110.0'
+ 'starlette==0.36.3'
+ 'pydantic==2.9.2'
+ 'pydantic-core==2.23.4'
+ 'uvicorn==0.52.4'
+ 'ray==2.55.1'
+ 'tensorflow-metadata==1.21.0'
+ 'typeguard==4.5.2'
+ 'gdown==6.1.0'
+ 'pymunk==7.3.0'
+ 'zarr==3.0.0a5'
+ 'google-api-core==2.30.3'
+ 'googleapis-common-protos==1.75.0'
+ 'proto-plus==1.28.0'
+ 'beautifulsoup4==4.15.0'
+ 'soupsieve==2.8.4'
+ 'asciitree==0.3.3'
+ 'crc32c==2.8'
+ 'donfig==0.8.1.post1'
+ 'numcodecs==0.13.1'
+ 'torchcodec==0.2.0'
+ 'rlinf-openpi==0.1.1'
+ 'rlinf-transformer-openpi==4.53.2'
+ 'lerobot==0.3.3'
+ 'openpi-client==0.1.2'
+)
+CUROBO_COMMIT=4ea77366ca48ee453e7df139e39fa6532af49f3b # v0.8.0
+CUROBO_CONSTRAINTS="${LOG_DIR}/curobo-constraints.txt"
+printf '%s\n' "${FINAL_PINS[@]}" \
+ 'torch==2.5.1+cu124' 'torchvision==0.20.1+cu124' 'torchaudio==2.5.1+cu124' \
+ 'isaacsim==4.5.0.0' 'isaacsim-core==4.5.0.0' 'isaacsim-rl==4.5.0.0' \
+ 'omnigibson==3.7.2' 'transformers==4.53.2' 'trimesh==5.1.0' \
+ > "${CUROBO_CONSTRAINTS}"
+"${UV_BIN}" pip install --python "${BEHAVIOR_PYTHON}" \
+ --constraint "${CUROBO_CONSTRAINTS}" \
+ "nvidia-curobo[cu12-torch] @ git+https://github.com/NVlabs/curobo.git@${CUROBO_COMMIT}"
+
+# Final compatibility repin runs after every dependency installer. --no-deps
+# prevents a late resolver pass from silently changing Isaac/OpenPI versions.
+"${UV_BIN}" pip install --python "${BEHAVIOR_PYTHON}" --no-deps "${FINAL_PINS[@]}"
+
+# OpenPI's replacement files must match the final transformers build. No package
+# installation is allowed after this block.
+"${UV_BIN}" pip install --python "${BEHAVIOR_PYTHON}" --no-deps \
+ 'transformers==4.53.2' 'tokenizers==0.21.4' 'huggingface-hub==0.36.2'
+SITE_PACKAGES="$("${BEHAVIOR_PYTHON}" -c 'import site; print(site.getsitepackages()[0])')"
+TRANSFORMERS_REPLACE="${SITE_PACKAGES}/openpi/models_pytorch/transformers_replace"
+if [[ ! -d "${TRANSFORMERS_REPLACE}" ]]; then
+ echo "Missing OpenPI transformer replacement directory: ${TRANSFORMERS_REPLACE}" >&2
+ exit 1
+fi
+cp -a "${TRANSFORMERS_REPLACE}/." "${SITE_PACKAGES}/transformers/"
+
+"${BEHAVIOR_PYTHON}" - <<'PY'
+from importlib.metadata import version
+from pathlib import Path
+import hashlib
+import site
+
+expected = {
+ "torch": "2.5.1+cu124",
+ "torchvision": "0.20.1+cu124",
+ "torchaudio": "2.5.1+cu124",
+ "numpy": "1.26.4",
+ "fastapi": "0.110.0",
+ "starlette": "0.36.3",
+ "pydantic": "2.9.2",
+ "transformers": "4.53.2",
+ "tokenizers": "0.21.4",
+ "rlinf-openpi": "0.1.1",
+ "rlinf-transformer-openpi": "4.53.2",
+ "lerobot": "0.3.3",
+}
+for package, wanted in expected.items():
+ actual = version(package)
+ if actual != wanted:
+ raise RuntimeError(f"{package}: expected {wanted}, got {actual}")
+
+root = Path(site.getsitepackages()[0])
+replacement = root / "openpi/models_pytorch/transformers_replace"
+target = root / "transformers"
+files = sorted(path.relative_to(replacement) for path in replacement.rglob("*.py"))
+if not files:
+ raise RuntimeError("OpenPI transformer replacement contains no Python files")
+for relative in files:
+ source_hash = hashlib.sha256((replacement / relative).read_bytes()).digest()
+ target_hash = hashlib.sha256((target / relative).read_bytes()).digest()
+ if source_hash != target_hash:
+ raise RuntimeError(f"transformer replacement mismatch: {relative}")
+
+import torch
+import omnigibson
+import ray
+import rlinf
+import openpi
+
+if not torch.cuda.is_available():
+ raise RuntimeError("torch.cuda.is_available() is false")
+tensor = torch.ones(1, device="cuda")
+print("CUDA smoke:", tensor, torch.cuda.get_device_name(0))
+print("Critical imports and transformer replacement: OK")
+PY
+
+MANIFEST_DIR="${REPRO_ROOT}/manifests"
+mkdir -p "${MANIFEST_DIR}"
+"${UV_BIN}" pip freeze --python "${RPENT_VENV}/bin/python" \
+ > "${MANIFEST_DIR}/rpent-venv.freeze.txt"
+"${UV_BIN}" pip freeze --python "${BEHAVIOR_PYTHON}" \
+ > "${MANIFEST_DIR}/behavior-venv.freeze.txt"
+{
+ echo "generated_at_utc=$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "rpent_commit=$(git -C "${RPENT_ROOT}" rev-parse HEAD)"
+ echo "rpent_dirty=$(test -n "$(git -C "${RPENT_ROOT}" status --porcelain)" && echo true || echo false)"
+ echo "rlinf_commit=$(git -C "${RLINF_ROOT}" rev-parse HEAD)"
+ echo "uv_version=$("${UV_BIN}" --version)"
+ echo "python_version=$("${BEHAVIOR_PYTHON}" --version 2>&1)"
+} > "${MANIFEST_DIR}/source-versions.txt"
+
+echo "Running uv dependency metadata check (report-only for upstream pin conflicts)."
+"${UV_BIN}" pip check --python "${BEHAVIOR_PYTHON}" \
+ > "${MANIFEST_DIR}/behavior-pip-check.txt" 2>&1 || true
+cat "${MANIFEST_DIR}/behavior-pip-check.txt"
+
+cd "${RPENT_ROOT}"
+"${RPENT_VENV}/bin/python" -m robots.behavior.selfcheck
+
+echo "Installation complete."
+echo "Behavior Python: ${BEHAVIOR_PYTHON}"
+echo "Version manifests: ${MANIFEST_DIR}"
+echo "Next: export the asset variables and run behavior-download-assets --verify"
diff --git a/robots/behavior/install_runtime.py b/robots/behavior/install_runtime.py
new file mode 100644
index 000000000..321769715
--- /dev/null
+++ b/robots/behavior/install_runtime.py
@@ -0,0 +1,49 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Console entry point for the reviewed BEHAVIOR dual-venv installer.
+
+This command requires an RPent source checkout with ``robots.behavior``
+available through editable install.
+"""
+
+from __future__ import annotations
+
+import argparse
+import subprocess
+from collections.abc import Sequence
+from pathlib import Path
+
+
+def _build_parser() -> argparse.ArgumentParser:
+ return argparse.ArgumentParser(
+ prog="behavior-install-runtime",
+ description=(
+ "Install the reviewed BEHAVIOR dual-venv runtime. Paths and version "
+ "pins are controlled by the environment variables documented in "
+ "the BEHAVIOR usage guide."
+ ),
+ )
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ _build_parser().parse_args(argv)
+ script = Path(__file__).with_name("install_behavior_runtime.sh")
+ if not script.is_file():
+ raise RuntimeError(f"packaged BEHAVIOR installer is missing: {script}")
+ return subprocess.run(["bash", str(script)], check=False).returncode
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/robots/behavior/memory/__init__.py b/robots/behavior/memory/__init__.py
new file mode 100644
index 000000000..641bd1c0e
--- /dev/null
+++ b/robots/behavior/memory/__init__.py
@@ -0,0 +1,75 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR episode-memory index and validation schema."""
+
+from typing import TYPE_CHECKING, Any
+
+from robots.behavior.memory.schema import (
+ MemoryValidationError,
+ canonical_json_bytes,
+ canonical_json_file_bytes,
+ require_sha256,
+ sha256_bytes,
+)
+
+if TYPE_CHECKING:
+ from robots.behavior.memory.index import (
+ EpisodeExperience,
+ EpisodeFrameKey,
+ EpisodeMemoryHit,
+ EpisodeMemoryIndex,
+ empty_episode_memory_index,
+ load_current_catalog,
+ load_revision_dir,
+ write_candidate_revision,
+ )
+
+_INDEX_EXPORTS = frozenset(
+ {
+ "EpisodeExperience",
+ "EpisodeFrameKey",
+ "EpisodeMemoryHit",
+ "EpisodeMemoryIndex",
+ "empty_episode_memory_index",
+ "load_current_catalog",
+ "load_revision_dir",
+ "write_candidate_revision",
+ }
+)
+
+
+def __getattr__(name: str) -> Any:
+ if name not in _INDEX_EXPORTS:
+ raise AttributeError(name)
+ from robots.behavior.memory import index
+
+ return getattr(index, name)
+
+
+__all__ = [
+ "EpisodeExperience",
+ "EpisodeFrameKey",
+ "EpisodeMemoryHit",
+ "EpisodeMemoryIndex",
+ "MemoryValidationError",
+ "canonical_json_bytes",
+ "canonical_json_file_bytes",
+ "empty_episode_memory_index",
+ "load_current_catalog",
+ "load_revision_dir",
+ "require_sha256",
+ "sha256_bytes",
+ "write_candidate_revision",
+]
diff --git a/robots/behavior/memory/index.py b/robots/behavior/memory/index.py
new file mode 100644
index 000000000..b606473a8
--- /dev/null
+++ b/robots/behavior/memory/index.py
@@ -0,0 +1,767 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Production episode-level BEHAVIOR memory index.
+
+Only head DINOv2 CLS384 keyframes are active. Wrist embeddings may be carried
+for audit and shadow distances, but they never decide use vs record.
+"""
+
+from __future__ import annotations
+
+import io
+import json
+import os
+import tempfile
+from collections.abc import Mapping, Sequence
+from dataclasses import dataclass, field
+from pathlib import Path
+from types import MappingProxyType
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.dino_v2.encoder import (
+ DINOV2_DIMENSION,
+ DISTANCE_METRIC,
+ l2_matrix,
+ l2_normalize_row,
+)
+from robots.behavior.memory.schema import (
+ MemoryValidationError,
+ canonical_json_file_bytes,
+ fail,
+ require_exact_keys,
+ require_sha256,
+ sha256_bytes,
+)
+
+SCHEMA_ID = "rpent_behavior_episode_memory_index_v1"
+REVISION_SCHEMA_ID = "rpent_behavior_episode_memory_revision_v1"
+CURRENT_POINTER_SCHEMA_ID = "rpent_behavior_episode_memory_current_v1"
+MANIFEST_SCHEMA_ID = "rpent_behavior_episode_memory_manifest_v1"
+HEAD_ACTIVE_DISTANCE_MAX = 0.05367707759141922
+ACTIVE_CHANNEL = "head"
+SHADOW_CHANNELS = ("left_wrist", "right_wrist")
+
+
+def _nonempty_string(value: Any, *, path: str) -> str:
+ if not isinstance(value, str) or not value.strip() or "\x00" in value:
+ fail("MEMORY_EPISODE_SCHEMA_INVALID", path, "expected non-empty string")
+ return value.strip()
+
+
+@dataclass(frozen=True, slots=True)
+class EpisodeFrameKey:
+ frame_id: str
+ episode_id: str
+ experience_id: str
+ task_name: str
+ frame_index: int
+ embedding_row: int
+ keyframe_kind: str
+ source_record_id: str
+ frame_identity: Mapping[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self) -> None:
+ for field_name in (
+ "frame_id",
+ "episode_id",
+ "experience_id",
+ "task_name",
+ "keyframe_kind",
+ "source_record_id",
+ ):
+ _nonempty_string(getattr(self, field_name), path=f"frame.{field_name}")
+ if isinstance(self.frame_index, bool) or self.frame_index < 0:
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "frame.frame_index",
+ "expected non-negative int",
+ )
+ if isinstance(self.embedding_row, bool) or self.embedding_row < 0:
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "frame.embedding_row",
+ "expected non-negative int",
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "frame_id": self.frame_id,
+ "episode_id": self.episode_id,
+ "experience_id": self.experience_id,
+ "task_name": self.task_name,
+ "frame_index": self.frame_index,
+ "embedding_row": self.embedding_row,
+ "keyframe_kind": self.keyframe_kind,
+ "source_record_id": self.source_record_id,
+ "frame_identity": dict(self.frame_identity),
+ }
+
+ @classmethod
+ def from_mapping(cls, value: Mapping[str, Any]) -> "EpisodeFrameKey":
+ require_exact_keys(
+ value,
+ {
+ "frame_id",
+ "episode_id",
+ "experience_id",
+ "task_name",
+ "frame_index",
+ "embedding_row",
+ "keyframe_kind",
+ "source_record_id",
+ "frame_identity",
+ },
+ path="frame",
+ )
+ return cls(
+ frame_id=str(value["frame_id"]),
+ episode_id=str(value["episode_id"]),
+ experience_id=str(value["experience_id"]),
+ task_name=str(value["task_name"]),
+ frame_index=int(value["frame_index"]),
+ embedding_row=int(value["embedding_row"]),
+ keyframe_kind=str(value["keyframe_kind"]),
+ source_record_id=str(value["source_record_id"]),
+ frame_identity=dict(value["frame_identity"]),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class EpisodeExperience:
+ episode_id: str
+ experience_id: str
+ logical_experience_id: str
+ task_name: str
+ usage: Mapping[str, Any]
+ outcome: Mapping[str, Any]
+ frame_keys: tuple[EpisodeFrameKey, ...]
+ canonical_trajectory_ref: Mapping[str, Any] | None = None
+ trajectory_refs: tuple[Mapping[str, Any], ...] = ()
+ reproduction_evidence: tuple[Mapping[str, Any], ...] = ()
+ source: Mapping[str, Any] = field(default_factory=dict)
+ metadata: Mapping[str, Any] = field(default_factory=dict)
+
+ def __post_init__(self) -> None:
+ for field_name in (
+ "episode_id",
+ "experience_id",
+ "logical_experience_id",
+ "task_name",
+ ):
+ _nonempty_string(getattr(self, field_name), path=f"experience.{field_name}")
+ if not self.frame_keys:
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "experience.frame_keys",
+ "at least one head keyframe required",
+ )
+ for frame in self.frame_keys:
+ if (
+ frame.episode_id != self.episode_id
+ or frame.experience_id != self.experience_id
+ or frame.task_name != self.task_name
+ ):
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "experience.frame_keys",
+ "frame identity does not match experience",
+ )
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema_id": SCHEMA_ID,
+ "episode_id": self.episode_id,
+ "experience_id": self.experience_id,
+ "logical_experience_id": self.logical_experience_id,
+ "task_name": self.task_name,
+ "usage": dict(self.usage),
+ "outcome": dict(self.outcome),
+ "canonical_trajectory_ref": None
+ if self.canonical_trajectory_ref is None
+ else dict(self.canonical_trajectory_ref),
+ "trajectory_refs": [dict(item) for item in self.trajectory_refs],
+ "reproduction_evidence": [
+ dict(item) for item in self.reproduction_evidence
+ ],
+ "source": dict(self.source),
+ "metadata": dict(self.metadata),
+ "frame_keys": [frame.to_dict() for frame in self.frame_keys],
+ }
+
+ @classmethod
+ def from_mapping(cls, value: Mapping[str, Any]) -> "EpisodeExperience":
+ require_exact_keys(
+ value,
+ {
+ "schema_id",
+ "episode_id",
+ "experience_id",
+ "logical_experience_id",
+ "task_name",
+ "usage",
+ "outcome",
+ "canonical_trajectory_ref",
+ "trajectory_refs",
+ "reproduction_evidence",
+ "source",
+ "metadata",
+ "frame_keys",
+ },
+ path="experience",
+ )
+ if value["schema_id"] != SCHEMA_ID:
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "experience.schema_id",
+ "schema mismatch",
+ )
+ frame_values = value["frame_keys"]
+ if not isinstance(frame_values, list):
+ fail(
+ "MEMORY_EPISODE_SCHEMA_INVALID",
+ "experience.frame_keys",
+ "expected list",
+ )
+ return cls(
+ episode_id=str(value["episode_id"]),
+ experience_id=str(value["experience_id"]),
+ logical_experience_id=str(value["logical_experience_id"]),
+ task_name=str(value["task_name"]),
+ usage=dict(value["usage"]),
+ outcome=dict(value["outcome"]),
+ canonical_trajectory_ref=None
+ if value["canonical_trajectory_ref"] is None
+ else dict(value["canonical_trajectory_ref"]),
+ trajectory_refs=tuple(dict(item) for item in value["trajectory_refs"]),
+ reproduction_evidence=tuple(
+ dict(item) for item in value["reproduction_evidence"]
+ ),
+ source=dict(value["source"]),
+ metadata=dict(value["metadata"]),
+ frame_keys=tuple(
+ EpisodeFrameKey.from_mapping(item) for item in frame_values
+ ),
+ )
+
+
+@dataclass(frozen=True, slots=True)
+class EpisodeMemoryHit:
+ rank: int
+ distance: float
+ matched_frame: EpisodeFrameKey
+ experience: EpisodeExperience
+ shadow_distances: Mapping[str, float] = field(default_factory=dict)
+
+ def to_dict(self) -> dict[str, Any]:
+ return {
+ "schema_id": "rpent_behavior_episode_memory_hit_v1",
+ "rank": self.rank,
+ "distance": self.distance,
+ "distance_metric": DISTANCE_METRIC,
+ "threshold": HEAD_ACTIVE_DISTANCE_MAX,
+ "episode_id": self.experience.episode_id,
+ "experience_id": self.experience.experience_id,
+ "logical_experience_id": self.experience.logical_experience_id,
+ "task_name": self.experience.task_name,
+ "usage": dict(self.experience.usage),
+ "outcome": dict(self.experience.outcome),
+ "matched_frame": self.matched_frame.to_dict(),
+ "experience": self.experience.to_dict(),
+ "returned_scope": "whole_experience",
+ "stage_inference": None,
+ "wrist_shadow_only": True,
+ "shadow_distances": dict(self.shadow_distances),
+ }
+
+
+class EpisodeMemoryIndex:
+ def __init__(
+ self,
+ *,
+ experiences: Sequence[EpisodeExperience],
+ head_embeddings: np.ndarray,
+ wrist_shadow_embeddings: Mapping[str, np.ndarray] | None = None,
+ revision: Mapping[str, Any] | None = None,
+ ) -> None:
+ self._experiences = tuple(experiences)
+ self._frames = tuple(
+ frame for exp in self._experiences for frame in exp.frame_keys
+ )
+ self._head = l2_matrix(head_embeddings, path="head_embeddings")
+ if self._head.shape[0] != len(self._frames):
+ fail(
+ "MEMORY_EPISODE_INDEX_INVALID",
+ "head_embeddings",
+ "row count must equal head keyframes",
+ )
+ self._experience_by_id = {
+ item.experience_id: item for item in self._experiences
+ }
+ episode_ids = {item.episode_id for item in self._experiences}
+ if len(self._experience_by_id) != len(self._experiences) or len(
+ episode_ids
+ ) != len(self._experiences):
+ fail(
+ "MEMORY_EPISODE_INDEX_INVALID",
+ "experiences",
+ "experience and episode IDs must be unique",
+ )
+ by_task: dict[str, list[int]] = {}
+ for index, frame in enumerate(self._frames):
+ if frame.embedding_row != index:
+ fail(
+ "MEMORY_EPISODE_INDEX_INVALID",
+ "frames",
+ "embedding rows must be contiguous",
+ )
+ by_task.setdefault(frame.task_name, []).append(index)
+ self._by_task = {task: tuple(indices) for task, indices in by_task.items()}
+ shadow: dict[str, np.ndarray] = {}
+ for channel, values in (wrist_shadow_embeddings or {}).items():
+ name = str(channel)
+ if name not in SHADOW_CHANNELS:
+ fail(
+ "MEMORY_EPISODE_INDEX_INVALID",
+ f"shadow.{name}",
+ "only wrist shadow channels are accepted",
+ )
+ matrix = l2_matrix(values, path=f"shadow.{name}")
+ if matrix.shape[0] != len(self._frames):
+ fail(
+ "MEMORY_EPISODE_INDEX_INVALID",
+ f"shadow.{name}",
+ "row count mismatch",
+ )
+ shadow[name] = matrix
+ self._shadow = MappingProxyType(shadow)
+ self._revision = MappingProxyType(dict(revision or {}))
+
+ @property
+ def episode_count(self) -> int:
+ return len(self._experiences)
+
+ @property
+ def frame_count(self) -> int:
+ return len(self._frames)
+
+ @property
+ def experiences(self) -> tuple[EpisodeExperience, ...]:
+ return self._experiences
+
+ @property
+ def revision(self) -> Mapping[str, Any]:
+ return self._revision
+
+ def search(
+ self,
+ *,
+ task_name: str,
+ head_embedding: np.ndarray,
+ k: int = 1,
+ wrist_shadow_embeddings: Mapping[str, np.ndarray] | None = None,
+ ) -> tuple[EpisodeMemoryHit, ...]:
+ task = _nonempty_string(task_name, path="query.task_name")
+ if isinstance(k, bool) or k < 1:
+ fail("MEMORY_EPISODE_QUERY_INVALID", "query.k", "expected positive int")
+ candidates = self._by_task.get(task, ())
+ if not candidates:
+ return ()
+ query = l2_normalize_row(head_embedding, path="query.head_embedding")[None, :]
+ distances = np.asarray(
+ 1.0 - np.clip(query @ self._head[list(candidates)].T, -1.0, 1.0),
+ dtype=np.float64,
+ )[0]
+ best_by_experience: dict[
+ str, tuple[float, EpisodeFrameKey, dict[str, float]]
+ ] = {}
+ for offset, frame_index in enumerate(candidates):
+ frame = self._frames[frame_index]
+ shadow_distances = self._shadow_distances(
+ frame_index, wrist_shadow_embeddings
+ )
+ candidate = (float(distances[offset]), frame, shadow_distances)
+ current = best_by_experience.get(frame.experience_id)
+ if current is None or (candidate[0], frame.frame_id) < (
+ current[0],
+ current[1].frame_id,
+ ):
+ best_by_experience[frame.experience_id] = candidate
+ hits = [
+ EpisodeMemoryHit(
+ rank=0,
+ distance=distance,
+ matched_frame=frame,
+ experience=self._experience_by_id[frame.experience_id],
+ shadow_distances=MappingProxyType(shadow),
+ )
+ for distance, frame, shadow in best_by_experience.values()
+ ]
+ ordered = sorted(
+ hits,
+ key=lambda hit: (
+ hit.distance,
+ hit.experience.experience_id,
+ hit.matched_frame.frame_id,
+ ),
+ )
+ return tuple(
+ EpisodeMemoryHit(
+ rank=index,
+ distance=hit.distance,
+ matched_frame=hit.matched_frame,
+ experience=hit.experience,
+ shadow_distances=hit.shadow_distances,
+ )
+ for index, hit in enumerate(ordered[:k], start=1)
+ )
+
+ def retrieve(
+ self,
+ *,
+ task_name: str,
+ head_embedding: np.ndarray,
+ wrist_shadow_embeddings: Mapping[str, np.ndarray] | None = None,
+ ) -> Mapping[str, Any]:
+ hits = self.search(
+ task_name=task_name,
+ head_embedding=head_embedding,
+ k=max(1, self.episode_count),
+ wrist_shadow_embeddings=wrist_shadow_embeddings,
+ )
+ selected = next(
+ (hit for hit in hits if hit.distance <= HEAD_ACTIVE_DISTANCE_MAX), None
+ )
+ return MappingProxyType(
+ {
+ "schema_id": "rpent_behavior_episode_memory_retrieval_v1",
+ "decision": "use_experience" if selected is not None else "record_new",
+ "reason": "head_keyframe_under_active_threshold"
+ if selected is not None
+ else "no_same_task_head_keyframe_under_active_threshold",
+ "task_filter_applied_before_vision": True,
+ "active_channel": ACTIVE_CHANNEL,
+ "head_active_distance_max": HEAD_ACTIVE_DISTANCE_MAX,
+ "wrist_shadow_only": True,
+ "hit": None if selected is None else selected.to_dict(),
+ "stage_inference": None,
+ "candidate_count_after_task_filter": len(
+ self._by_task.get(str(task_name).strip(), ())
+ ),
+ }
+ )
+
+ def _shadow_distances(
+ self,
+ frame_index: int,
+ queries: Mapping[str, np.ndarray] | None,
+ ) -> dict[str, float]:
+ result: dict[str, float] = {}
+ for channel, query in (queries or {}).items():
+ name = str(channel)
+ if name not in self._shadow or query is None:
+ continue
+ row = l2_normalize_row(query, path=f"query.{name}")[None, :]
+ result[name] = float(
+ 1.0
+ - np.clip(
+ row @ self._shadow[name][frame_index : frame_index + 1].T, -1.0, 1.0
+ )[0, 0]
+ )
+ return result
+
+
+def empty_episode_memory_index() -> EpisodeMemoryIndex:
+ return EpisodeMemoryIndex(
+ experiences=(),
+ head_embeddings=np.zeros((0, DINOV2_DIMENSION), dtype=np.float32),
+ revision={
+ "schema_id": REVISION_SCHEMA_ID,
+ "empty_catalog_reason": "memory_dir_omitted",
+ "activation_allowed": False,
+ },
+ )
+
+
+def load_current_catalog(memory_dir: Path | None) -> EpisodeMemoryIndex:
+ """Load the current catalog; omitted memory_dir is the only legal empty catalog."""
+
+ if memory_dir is None:
+ return empty_episode_memory_index()
+ root = Path(memory_dir)
+ if not root.is_dir():
+ fail(
+ "MEMORY_EPISODE_CATALOG_MISSING",
+ str(root),
+ "explicit memory-dir is missing",
+ )
+ pointer_path = root / "current.json"
+ pointer = _read_json(pointer_path)
+ require_exact_keys(
+ pointer, {"schema_id", "revision_document_sha256"}, path="current.json"
+ )
+ if pointer["schema_id"] != CURRENT_POINTER_SCHEMA_ID:
+ fail("MEMORY_EPISODE_POINTER_INVALID", "current.json", "schema mismatch")
+ revision_sha = require_sha256(
+ pointer["revision_document_sha256"], path="current.revision_document_sha256"
+ )
+ revision_dir = root / "revisions" / revision_sha
+ return load_revision_dir(revision_dir, expected_revision_sha256=revision_sha)
+
+
+def load_revision_dir(
+ revision_dir: Path, *, expected_revision_sha256: str | None = None
+) -> EpisodeMemoryIndex:
+ if not revision_dir.is_dir():
+ fail(
+ "MEMORY_EPISODE_REVISION_MISSING",
+ str(revision_dir),
+ "revision directory missing",
+ )
+ manifest = _read_json(revision_dir / "manifest.json")
+ require_exact_keys(
+ manifest,
+ {
+ "schema_id",
+ "revision_document_sha256",
+ "catalog_sha256",
+ "embeddings_npz_sha256",
+ "experience_count",
+ "frame_count",
+ },
+ path="manifest.json",
+ )
+ if manifest["schema_id"] != MANIFEST_SCHEMA_ID:
+ fail("MEMORY_EPISODE_MANIFEST_INVALID", "manifest.schema_id", "schema mismatch")
+ revision_sha = require_sha256(
+ manifest["revision_document_sha256"], path="manifest.revision_document_sha256"
+ )
+ if (
+ expected_revision_sha256 is not None
+ and revision_sha != expected_revision_sha256
+ ):
+ fail(
+ "MEMORY_EPISODE_HASH_MISMATCH",
+ "manifest.revision_document_sha256",
+ "current pointer mismatch",
+ )
+ revision_bytes = _read_regular(revision_dir / "revision.json")
+ if sha256_bytes(revision_bytes) != revision_sha:
+ fail(
+ "MEMORY_EPISODE_HASH_MISMATCH", "revision.json", "document digest mismatch"
+ )
+ catalog_bytes = _read_regular(revision_dir / "catalog.jsonl")
+ if sha256_bytes(catalog_bytes) != require_sha256(
+ manifest["catalog_sha256"], path="manifest.catalog_sha256"
+ ):
+ fail("MEMORY_EPISODE_HASH_MISMATCH", "catalog.jsonl", "catalog digest mismatch")
+ embeddings_bytes = _read_regular(revision_dir / "embeddings.npz")
+ if sha256_bytes(embeddings_bytes) != require_sha256(
+ manifest["embeddings_npz_sha256"], path="manifest.embeddings_npz_sha256"
+ ):
+ fail(
+ "MEMORY_EPISODE_HASH_MISMATCH",
+ "embeddings.npz",
+ "embedding digest mismatch",
+ )
+ revision = json.loads(revision_bytes.decode("utf-8"))
+ experiences = tuple(
+ EpisodeExperience.from_mapping(json.loads(line.decode("utf-8")))
+ for line in catalog_bytes.splitlines()
+ if line
+ )
+ with np.load(io.BytesIO(embeddings_bytes), allow_pickle=False) as data:
+ head = np.asarray(data["head"], dtype=np.float32)
+ shadow = {
+ name: np.asarray(data[name], dtype=np.float32)
+ for name in SHADOW_CHANNELS
+ if name in data.files
+ }
+ index = EpisodeMemoryIndex(
+ experiences=experiences,
+ head_embeddings=head,
+ wrist_shadow_embeddings=shadow,
+ revision=revision,
+ )
+ if index.episode_count != int(
+ manifest["experience_count"]
+ ) or index.frame_count != int(manifest["frame_count"]):
+ fail("MEMORY_EPISODE_MANIFEST_INVALID", "manifest.counts", "count mismatch")
+ return index
+
+
+def write_candidate_revision(
+ *,
+ memory_dir: Path,
+ experiences: Sequence[EpisodeExperience],
+ head_embeddings: np.ndarray,
+ wrist_shadow_embeddings: Mapping[str, np.ndarray] | None = None,
+ encoder_identity: Mapping[str, Any] | None = None,
+ parent_revision_document_sha256: str | None = None,
+ activate_current: bool = True,
+) -> Mapping[str, Any]:
+ """Validate, write content-addressed revision, then atomically advance current."""
+
+ root = Path(memory_dir)
+ root.mkdir(parents=True, exist_ok=True)
+ candidate_index = EpisodeMemoryIndex(
+ experiences=experiences,
+ head_embeddings=head_embeddings,
+ wrist_shadow_embeddings=wrist_shadow_embeddings,
+ )
+ catalog_bytes = b"".join(
+ canonical_json_file_bytes(exp.to_dict(), path=f"experience[{index}]")
+ for index, exp in enumerate(candidate_index.experiences)
+ )
+ embedding_payload = _npz_bytes(
+ {"head": candidate_index._head, **dict(candidate_index._shadow)}
+ )
+ catalog_sha = sha256_bytes(catalog_bytes)
+ embeddings_sha = sha256_bytes(embedding_payload)
+ revision = {
+ "schema_id": REVISION_SCHEMA_ID,
+ "format_version": 1,
+ "preliminary": True,
+ "activation_allowed": False,
+ "active_thresholds": {"head_distance_max": HEAD_ACTIVE_DISTANCE_MAX},
+ "distance_metric": DISTANCE_METRIC,
+ "active_channel": ACTIVE_CHANNEL,
+ "wrist_policy": "shadow_only",
+ "encoder_identity": dict(encoder_identity or {}),
+ "parent_revision_document_sha256": parent_revision_document_sha256,
+ "catalog_sha256": catalog_sha,
+ "embeddings_npz_sha256": embeddings_sha,
+ "experience_count": candidate_index.episode_count,
+ "frame_count": candidate_index.frame_count,
+ }
+ revision_bytes = canonical_json_file_bytes(revision, path="revision")
+ revision_sha = sha256_bytes(revision_bytes)
+ revision_dir = root / "revisions" / revision_sha
+ _write_revision_dir(
+ revision_dir,
+ revision_bytes=revision_bytes,
+ catalog_bytes=catalog_bytes,
+ embedding_bytes=embedding_payload,
+ manifest={
+ "schema_id": MANIFEST_SCHEMA_ID,
+ "revision_document_sha256": revision_sha,
+ "catalog_sha256": catalog_sha,
+ "embeddings_npz_sha256": embeddings_sha,
+ "experience_count": candidate_index.episode_count,
+ "frame_count": candidate_index.frame_count,
+ },
+ )
+ load_revision_dir(revision_dir, expected_revision_sha256=revision_sha)
+ pointer = {
+ "schema_id": CURRENT_POINTER_SCHEMA_ID,
+ "revision_document_sha256": revision_sha,
+ }
+ if activate_current:
+ _atomic_write(
+ root / "current.json", canonical_json_file_bytes(pointer, path="current")
+ )
+ return MappingProxyType(
+ {
+ "revision_document_sha256": revision_sha,
+ "revision_dir": str(revision_dir),
+ "current": bool(activate_current),
+ }
+ )
+
+
+def _npz_bytes(arrays: Mapping[str, np.ndarray]) -> bytes:
+ with io.BytesIO() as buffer:
+ np.savez(
+ buffer,
+ **{
+ name: np.asarray(value, dtype=np.float32)
+ for name, value in arrays.items()
+ },
+ )
+ return buffer.getvalue()
+
+
+def _read_regular(path: Path) -> bytes:
+ if path.is_symlink() or not path.is_file():
+ fail("MEMORY_EPISODE_SOURCE_INVALID", str(path), "expected regular file")
+ return path.read_bytes()
+
+
+def _read_json(path: Path) -> Mapping[str, Any]:
+ try:
+ value = json.loads(_read_regular(path).decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ fail("MEMORY_EPISODE_SOURCE_INVALID", str(path), str(exc))
+ if not isinstance(value, Mapping):
+ fail("MEMORY_EPISODE_SOURCE_INVALID", str(path), "expected JSON object")
+ return value
+
+
+def _atomic_write(path: Path, payload: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ mode="wb", prefix=f".{path.name}.", dir=path.parent, delete=False
+ ) as handle:
+ tmp = Path(handle.name)
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ try:
+ os.replace(tmp, path)
+ finally:
+ if tmp.exists():
+ tmp.unlink()
+
+
+def _write_new(path: Path, payload: bytes) -> None:
+ if path.exists():
+ if path.is_file() and not path.is_symlink() and path.read_bytes() == payload:
+ return
+ fail("MEMORY_EPISODE_OUTPUT_COLLISION", str(path), "existing bytes differ")
+ _atomic_write(path, payload)
+
+
+def _write_revision_dir(
+ revision_dir: Path,
+ *,
+ revision_bytes: bytes,
+ catalog_bytes: bytes,
+ embedding_bytes: bytes,
+ manifest: Mapping[str, Any],
+) -> None:
+ revision_dir.mkdir(parents=True, exist_ok=True)
+ _write_new(revision_dir / "revision.json", revision_bytes)
+ _write_new(revision_dir / "catalog.jsonl", catalog_bytes)
+ _write_new(revision_dir / "embeddings.npz", embedding_bytes)
+ _write_new(
+ revision_dir / "manifest.json",
+ canonical_json_file_bytes(dict(manifest), path="manifest"),
+ )
+
+
+__all__ = [
+ "ACTIVE_CHANNEL",
+ "HEAD_ACTIVE_DISTANCE_MAX",
+ "EpisodeExperience",
+ "EpisodeFrameKey",
+ "EpisodeMemoryHit",
+ "EpisodeMemoryIndex",
+ "MemoryValidationError",
+ "empty_episode_memory_index",
+ "load_current_catalog",
+ "load_revision_dir",
+ "write_candidate_revision",
+]
diff --git a/robots/behavior/memory/schema.py b/robots/behavior/memory/schema.py
new file mode 100644
index 000000000..d4296f33f
--- /dev/null
+++ b/robots/behavior/memory/schema.py
@@ -0,0 +1,82 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Small deterministic schema helpers for BEHAVIOR episode memory."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import re
+from collections.abc import Mapping
+from typing import Any
+
+SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+
+
+class MemoryValidationError(ValueError):
+ """Fail-closed validation error with a stable code and path."""
+
+ def __init__(self, code: str, path: str, detail: str) -> None:
+ super().__init__(f"{code}: {path}: {detail}")
+ self.code = code
+ self.path = path
+ self.detail = detail
+
+
+def fail(code: str, path: str, detail: str) -> None:
+ raise MemoryValidationError(code, path, detail)
+
+
+def require_sha256(value: Any, *, path: str) -> str:
+ if not isinstance(value, str) or SHA256_PATTERN.fullmatch(value) is None:
+ fail("MEMORY_SCHEMA_INVALID", path, "expected one lowercase SHA-256 digest")
+ return value
+
+
+def canonical_json_bytes(value: Any, *, path: str = "$") -> bytes:
+ try:
+ return json.dumps(
+ value,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=True,
+ allow_nan=False,
+ ).encode("utf-8")
+ except (TypeError, ValueError) as exc:
+ fail("MEMORY_JSON_INVALID", path, f"{type(exc).__name__}: {exc}")
+
+
+def canonical_json_file_bytes(value: Any, *, path: str = "$") -> bytes:
+ return canonical_json_bytes(value, path=path) + b"\n"
+
+
+def sha256_bytes(payload: bytes) -> str:
+ return hashlib.sha256(payload).hexdigest()
+
+
+def require_exact_keys(
+ value: Mapping[str, Any],
+ expected: set[str] | frozenset[str],
+ *,
+ path: str,
+) -> None:
+ actual = set(value)
+ expected_set = set(expected)
+ if actual != expected_set:
+ fail(
+ "MEMORY_SCHEMA_INVALID",
+ path,
+ f"expected keys {sorted(expected_set)}, actual {sorted(actual)}",
+ )
diff --git a/robots/behavior/motion.py b/robots/behavior/motion.py
new file mode 100644
index 000000000..6daa948c5
--- /dev/null
+++ b/robots/behavior/motion.py
@@ -0,0 +1,382 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Read kinematics in the existing RLinf simulation actor's owning thread.
+
+Ray's built-in actor call executes this importable function serially alongside
+reset/step. No simulator object crosses the process boundary. Actions continue
+through OfficialBehaviorBackend.chunk_step, including official-success checks.
+"""
+
+from __future__ import annotations
+
+import numpy as np
+
+
+def get_camera_observation(process, env_index: int = 0) -> dict:
+ """Capture synchronized RGB, optical-axis depth and calibration without stepping."""
+ import omnigibson as og
+
+ from robots.behavior.rlinf_env import _torch_to_numpy
+
+ env = process.env.envs[env_index]
+ robot = env.robots[0]
+ cameras = {}
+ for camera, link in (
+ ("head", "zed_link"),
+ ("left_wrist", "left_realsense_link"),
+ ("right_wrist", "right_realsense_link"),
+ ):
+ sensors = [
+ sensor for name, sensor in robot.sensors.items() if f":{link}:" in name
+ ]
+ if len(sensors) != 1:
+ raise ValueError(f"expected one {camera} sensor, found {len(sensors)}")
+ sensor = sensors[0]
+ sensor.add_modality("depth_linear")
+ # Initialize the camera-parameter annotator before flushing render latency.
+ sensor.camera_parameters
+ cameras[camera] = sensor
+ env.load_observation_space()
+ for _ in range(3):
+ og.sim.render()
+ result = {}
+ for camera, sensor in cameras.items():
+ obs, _ = sensor.get_obs()
+ params = sensor.camera_parameters
+ view = (
+ np.asarray(_torch_to_numpy(params["cameraViewTransform"])).reshape(4, 4).T
+ )
+ result[camera] = {
+ "rgb": np.asarray(_torch_to_numpy(obs["rgb"]))[..., :3],
+ "depth": np.asarray(_torch_to_numpy(obs["depth_linear"])).squeeze(),
+ "intrinsic": np.asarray(_torch_to_numpy(sensor.intrinsic_matrix)),
+ "camera_to_world": np.linalg.inv(view),
+ }
+ return result
+
+
+def get_planning_state(process, env_index: int = 0) -> dict:
+ """Read the actor's current articulation and conservative collision geometry."""
+ from pathlib import Path
+
+ from robots.behavior.rlinf_env import _torch_to_numpy
+
+ env = process.env.envs[env_index]
+ robot = env.robots[0]
+ result = get_motion_state(process, env_index)
+ q = np.asarray(_torch_to_numpy(robot.get_joint_positions()))
+ result["joint_positions"] = dict(zip(robot.joints, q.tolist()))
+ result["urdf_path"] = robot.urdf_path
+ model_dir = Path(robot.urdf_path).parent.parent
+ result["collision_config_path"] = str(
+ model_dir / "curobo" / "r1pro_description_curobo_arm_no_torso.yaml"
+ )
+ pos, quat = robot.links["base_link"].get_position_orientation()
+ result["base_position"] = np.asarray(_torch_to_numpy(pos))
+ result["base_quaternion_xyzw"] = np.asarray(_torch_to_numpy(quat))
+ result["robot_aabb"] = [np.asarray(_torch_to_numpy(x)) for x in robot.aabb]
+ result["obstacles"] = {}
+ for obj in env.scene.objects:
+ if obj is robot:
+ continue
+ for name, link in obj.links.items():
+ low, high = (np.asarray(_torch_to_numpy(x)) for x in link.aabb)
+ if np.all(high > low):
+ result["obstacles"][f"{obj.name}_{name}"] = {"low": low, "high": high}
+ return result
+
+
+def navigation_collision(state: dict, goal: np.ndarray) -> str | None:
+ """Conservative swept footprint for a short holonomic base segment."""
+ low, high = map(np.asarray, state["robot_aabb"])
+ position = np.asarray(state["base_position"])
+ radius = float(
+ np.linalg.norm(np.maximum(high[:2] - position[:2], position[:2] - low[:2]))
+ )
+ for fraction in np.linspace(
+ 0, 1, max(2, int(np.linalg.norm(goal[:2] - position[:2]) / 0.01) + 1)
+ ):
+ point = position[:2] + fraction * (goal[:2] - position[:2])
+ for name, obstacle in state["obstacles"].items():
+ a, b = np.asarray(obstacle["low"]), np.asarray(obstacle["high"])
+ if b[2] <= low[2] or a[2] >= high[2]:
+ continue
+ nearest = np.clip(point, a[:2], b[:2])
+ if np.linalg.norm(nearest - point) <= radius:
+ return name
+ return None
+
+
+def build_robot_config(state: dict, hands=("left", "right")) -> dict:
+ """Adapt the installed R1Pro collision model to cuRobo 0.8's URDF loader."""
+ import xml.etree.ElementTree as ET
+
+ import yaml
+ from scipy.spatial.transform import Rotation
+
+ with open(state["collision_config_path"]) as source:
+ original = yaml.safe_load(source)["robot_cfg"]["kinematics"]
+ xml_joints = ET.parse(state["urdf_path"]).getroot().findall("joint")
+ joints = {j.attrib["name"] for j in xml_joints if j.attrib["type"] != "fixed"}
+ active = [f"{hand}_arm_joint{i}" for hand in hands for i in range(1, 8)]
+ # R1Pro's holonomic-base import fixes the six URDF wheel/steering joints
+ # at their zero transform; they are not DOFs in the live articulation.
+ fixed_wheels = {
+ f"{kind}_motor_joint{i}" for kind in ("wheel", "steer") for i in range(1, 4)
+ }
+ locked = {
+ name: state["joint_positions"][name]
+ for name in joints - set(active) - fixed_wheels
+ }
+ kinematics = {
+ name: original[name]
+ for name in (
+ "collision_link_names",
+ "collision_spheres",
+ "collision_sphere_buffer",
+ "self_collision_buffer",
+ "self_collision_ignore",
+ "extra_links",
+ "extra_collision_spheres",
+ )
+ }
+ # cuRobo 0.8's loader computes padding compensation in a local variable
+ # but passes the uncompensated dict to SelfCollisionKinematicsCfg. Restore
+ # its intended semantics here: external collision padding remains intact;
+ # self-collision uses the original spheres plus the explicit self margins.
+ buffer = original["collision_sphere_buffer"]
+ kinematics["self_collision_buffer"] = {
+ name: original["self_collision_buffer"].get(name, 0.0)
+ - (buffer if isinstance(buffer, (float, int)) else buffer.get(name, 0.0))
+ for name in original["collision_link_names"]
+ }
+ for joint in xml_joints:
+ if joint.attrib["name"] not in fixed_wheels:
+ continue
+ name = joint.find("child").attrib["link"]
+ origin = joint.find("origin")
+ xyz = [float(x) for x in origin.attrib.get("xyz", "0 0 0").split()]
+ rpy = [float(x) for x in origin.attrib.get("rpy", "0 0 0").split()]
+ quat = Rotation.from_euler("xyz", rpy).as_quat()[[3, 0, 1, 2]].tolist()
+ kinematics["extra_links"][name] = {
+ "link_name": name,
+ "parent_link_name": joint.find("parent").attrib["link"],
+ "joint_name": joint.attrib["name"],
+ "joint_type": "FIXED",
+ "fixed_transform": xyz + quat,
+ }
+ for hand in ("left", "right"):
+ name = f"{hand}_eef_link"
+ kinematics["extra_links"][name] = {
+ "link_name": name,
+ "parent_link_name": f"{hand}_gripper_link",
+ "joint_name": name + "_joint",
+ "joint_type": "FIXED",
+ # r1pro_source_cfg.yaml: xyzw [0,1,0,0], translated to wxyz here.
+ "fixed_transform": [0, 0, -0.06, 0, 0, 1, 0],
+ }
+ kinematics.update(
+ base_link="base_link",
+ urdf_path=state["urdf_path"],
+ tool_frames=[f"{hand}_eef_link" for hand in hands],
+ lock_joints=locked,
+ cspace={
+ "joint_names": active,
+ "default_joint_position": [state["joint_positions"][n] for n in active],
+ "cspace_distance_weight": [
+ original["cspace"]["cspace_distance_weight"][
+ original["cspace"]["joint_names"].index(n)
+ ]
+ for n in active
+ ],
+ "null_space_weight": [
+ original["cspace"]["null_space_weight"][
+ original["cspace"]["joint_names"].index(n)
+ ]
+ for n in active
+ ],
+ "max_acceleration": 5.0,
+ "max_jerk": 100.0,
+ "velocity_scale": 0.25,
+ },
+ )
+ return {"kinematics": kinematics}
+
+
+class BehaviorMotionPlanner:
+ """cuRobo 0.8 arm planner, constructed lazily on the ENV facade thread.
+
+ The live torso, unselected arm and grippers are locked. The world uses
+ conservative link AABBs; a blocked path is a failure, never a teleport.
+ """
+
+ def __init__(self):
+ self._planner = None
+ self._lock_key = None
+
+ def close(self):
+ if self._planner is not None:
+ self._planner.destroy()
+ self._planner = None
+ self._lock_key = None
+
+ def plan(self, state: dict, targets: dict) -> dict:
+ import torch
+ from curobo.motion_planner import MotionPlanner, MotionPlannerCfg
+ from curobo.scene import Scene
+ from curobo.types import GoalToolPose, JointState, Pose
+ from scipy.spatial.transform import Rotation
+
+ config = build_robot_config(state, tuple(targets))
+ kinematics = config["kinematics"]
+ locks = kinematics["lock_joints"]
+ key = (
+ tuple(targets),
+ tuple((n, round(v, 3)) for n, v in sorted(locks.items())),
+ )
+ base_rotation = Rotation.from_quat(state["base_quaternion_xyzw"])
+ inverse = base_rotation.inv()
+ orientation = inverse.as_quat()[[3, 0, 1, 2]].tolist()
+ cuboids = {}
+ for name, obstacle in state["obstacles"].items():
+ low, high = np.asarray(obstacle["low"]), np.asarray(obstacle["high"])
+ center = inverse.apply((low + high) / 2 - state["base_position"])
+ cuboids[name] = {
+ "dims": (high - low).tolist(),
+ "pose": center.tolist() + orientation,
+ }
+ scene = {"cuboid": cuboids}
+ if self._planner is None or self._lock_key != key:
+ self.close()
+ self._planner = MotionPlanner(
+ MotionPlannerCfg.create(
+ robot={"robot_cfg": config},
+ scene_model=scene,
+ collision_cache={"cuboid": max(len(cuboids), 1)},
+ self_collision_check=True,
+ max_batch_size=1,
+ max_goalset=1,
+ )
+ )
+ self._lock_key = key
+ else:
+ self._planner.update_world(Scene.create(scene))
+ planner = self._planner
+ q = torch.tensor(
+ [[state["joint_positions"][n] for n in planner.joint_names]],
+ device="cuda",
+ dtype=torch.float32,
+ )
+ start = JointState.from_position(q, joint_names=planner.joint_names)
+ fk = planner.compute_kinematics(start)
+ goals = {}
+ for hand, target in targets.items():
+ name = f"{hand}_eef_link"
+ actual = state["hands"][hand]
+ predicted = fk.tool_poses.get_link_pose(name)
+ predicted_world = (
+ base_rotation.apply(
+ predicted.position.detach().cpu().numpy().reshape(3)
+ )
+ + state["base_position"]
+ )
+ if np.linalg.norm(predicted_world - actual["position"]) > 0.005:
+ raise ValueError(f"{hand}: URDF/live kinematics mismatch")
+ relative = inverse.apply(
+ np.asarray(target["position"]) - state["base_position"]
+ )
+ quat = (inverse * Rotation.from_quat(target["quaternion_xyzw"])).as_quat()[
+ [3, 0, 1, 2]
+ ]
+ goals[name] = Pose(
+ torch.tensor(relative[None], device="cuda", dtype=torch.float32),
+ torch.tensor(quat[None], device="cuda", dtype=torch.float32),
+ )
+ goal = GoalToolPose.from_poses(goals, ordered_tool_frames=planner.tool_frames)
+ result = planner.plan_pose(goal, start, max_attempts=1)
+ if result is None or not bool(result.success.all()):
+ return {
+ "success": False,
+ "stop_reason": "planning_failed",
+ "details": str(getattr(result, "status", "no trajectory")),
+ }
+ trajectory = result.get_interpolated_plan().reorder(planner.joint_names)
+ positions = (
+ trajectory.position.detach()
+ .cpu()
+ .numpy()
+ .reshape(-1, len(planner.joint_names))
+ )
+ dt = float(planner.trajopt_solver.config.interpolation_dt)
+ return {
+ "success": True,
+ "positions": positions,
+ "joint_names": planner.joint_names,
+ "dt": dt,
+ }
+
+
+def get_motion_state(process, env_index: int = 0) -> dict:
+ import omnigibson as og
+ from omnigibson.utils.transform_utils import quat2mat
+
+ from robots.behavior.rlinf_env import _torch_to_numpy
+
+ env = process.env.envs[env_index]
+ robot = env.robots[0]
+ controls = robot.get_control_dict()
+ joint_positions = robot.get_joint_positions()
+ _, base_quat = robot.get_position_orientation()
+
+ def array(value):
+ return np.asarray(_torch_to_numpy(value)).copy()
+
+ rotation = array(quat2mat(base_quat))
+ result = {"control_dt": float(og.sim.get_sim_step_dt()), "hands": {}}
+ for hand in ("left", "right"):
+ controller = robot.controllers[f"arm_{hand}"]
+ if controller.motor_type != "position" or controller.use_delta_commands:
+ raise RuntimeError("motion requires absolute position arm controllers")
+ idx = robot.arm_control_idx[hand]
+ pos, quat = robot.get_eef_pose(hand)
+ jacobian = array(controls[f"eef_{hand}_jacobian_relative"][:, idx])
+ world_jacobian = np.concatenate(
+ (rotation @ jacobian[:3], rotation @ jacobian[3:]), axis=0
+ )
+ palm_pos, _ = robot.links[f"{hand}_gripper_link"].get_position_orientation()
+ direction = array(pos - palm_pos)
+ length = float(np.linalg.norm(direction))
+ if length < 1e-6:
+ raise RuntimeError("EEF and gripper origins do not define an approach axis")
+ contacts = sorted(
+ {
+ str(body)
+ for link in robot.finger_links[hand]
+ for contact in link.contact_list()
+ for body in (contact.body0, contact.body1)
+ if not str(body).startswith(robot.prim_path + "/")
+ }
+ )
+ result["hands"][hand] = {
+ "position": array(pos),
+ "quaternion_xyzw": array(quat),
+ "joint_positions": array(joint_positions[idx]),
+ "joint_lower_limits": array(robot.joint_lower_limits[idx]),
+ "joint_upper_limits": array(robot.joint_upper_limits[idx]),
+ "jacobian": world_jacobian,
+ "approach_direction": direction / length,
+ "contacts": contacts,
+ }
+ return result
diff --git a/robots/behavior/policy_checkpoint.py b/robots/behavior/policy_checkpoint.py
new file mode 100644
index 000000000..ffe83aeb8
--- /dev/null
+++ b/robots/behavior/policy_checkpoint.py
@@ -0,0 +1,230 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Identity contract for the shared BEHAVIOR Pi0.5 checkpoint."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Mapping
+
+POLICY_CHECKPOINT_BINDING_SCHEMA_VERSION = 1
+POLICY_CHECKPOINT_ENV = "PI05_CHECKPOINT_PATH"
+SHARED_POLICY_PROFILE_ID = "pi05-b1kpt50-cs32"
+SHARED_POLICY_CHECKPOINT_PATH = Path(
+ os.environ.get(POLICY_CHECKPOINT_ENV, SHARED_POLICY_PROFILE_ID)
+)
+
+
+class PolicyCheckpointError(ValueError):
+ """Raised when a checkpoint violates the shared BEHAVIOR contract."""
+
+
+@dataclass(frozen=True)
+class CheckpointFileRequirement:
+ relative_path: str
+ size_bytes: int
+ sha256: str
+
+
+@dataclass(frozen=True)
+class PolicyCheckpointProfile:
+ profile_id: str
+ files: tuple[CheckpointFileRequirement, ...]
+
+
+@dataclass(frozen=True)
+class PolicyCheckpointBinding:
+ schema_version: int
+ profile_id: str
+ resolved_path: str
+ files: tuple[CheckpointFileRequirement, ...]
+ binding_sha256: str
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "schema_version": self.schema_version,
+ "profile_id": self.profile_id,
+ "resolved_path": self.resolved_path,
+ "files": {
+ item.relative_path: {
+ "size_bytes": item.size_bytes,
+ "sha256": item.sha256,
+ }
+ for item in self.files
+ },
+ "binding_sha256": self.binding_sha256,
+ }
+
+
+SHARED_POLICY_PROFILE = PolicyCheckpointProfile(
+ profile_id=SHARED_POLICY_PROFILE_ID,
+ files=(
+ CheckpointFileRequirement(
+ relative_path="model.safetensors",
+ size_bytes=7_233_650_408,
+ sha256="7e257666d835f6af701de493676a6c86a0421b2efc737a0f911d782b7a09f635",
+ ),
+ CheckpointFileRequirement(
+ relative_path="config.json",
+ size_bytes=149,
+ sha256="a4ae208203adfdd64c5fdbd4b0dc257e4ebbc82e464cb146dd0377051b25fc0a",
+ ),
+ CheckpointFileRequirement(
+ relative_path="assets/behavior-1k/2025-challenge-demos/norm_stats.json",
+ size_bytes=6_368,
+ sha256="d66ed16830a98f90dde8a315058b4a0df59f5e05734c1686d8b3f66787d0a929",
+ ),
+ ),
+)
+
+
+def _canonical_sha256(value: Mapping[str, Any]) -> str:
+ return hashlib.sha256(
+ json.dumps(
+ value,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=True,
+ ).encode("utf-8")
+ ).hexdigest()
+
+
+def _file_sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _binding_payload(
+ profile: PolicyCheckpointProfile,
+ resolved_path: Path,
+) -> dict[str, Any]:
+ return {
+ "schema_version": POLICY_CHECKPOINT_BINDING_SCHEMA_VERSION,
+ "profile_id": profile.profile_id,
+ "resolved_path": str(resolved_path),
+ "files": {
+ item.relative_path: {
+ "size_bytes": item.size_bytes,
+ "sha256": item.sha256,
+ }
+ for item in profile.files
+ },
+ }
+
+
+def validate_policy_checkpoint(
+ path: str | Path = SHARED_POLICY_CHECKPOINT_PATH,
+) -> PolicyCheckpointBinding:
+ """Verify and bind the expected Pi05-Behavior checkpoint files."""
+
+ profile = SHARED_POLICY_PROFILE
+ requested = Path(path).expanduser()
+ try:
+ resolved = requested.resolve(strict=True)
+ except OSError as error:
+ raise PolicyCheckpointError(
+ f"your Pi05-Behavior model checkpoint is unavailable: {error}"
+ ) from error
+ if not resolved.is_dir():
+ raise PolicyCheckpointError(
+ f"your Pi05-Behavior model checkpoint is not a directory: {resolved}"
+ )
+ for requirement in profile.files:
+ candidate = resolved / requirement.relative_path
+ if candidate.is_symlink() or not candidate.is_file():
+ raise PolicyCheckpointError(
+ "your Pi05-Behavior model checkpoint file is missing or unsafe: "
+ f"{candidate}"
+ )
+ size = candidate.stat().st_size
+ if size != requirement.size_bytes:
+ raise PolicyCheckpointError(
+ "your Pi05-Behavior model checkpoint size mismatch for "
+ f"{requirement.relative_path}: expected {requirement.size_bytes}, "
+ f"got {size}"
+ )
+ actual_sha256 = _file_sha256(candidate)
+ if actual_sha256 != requirement.sha256:
+ raise PolicyCheckpointError(
+ "your Pi05-Behavior model checkpoint SHA256 mismatch for "
+ f"{requirement.relative_path}: expected {requirement.sha256}, "
+ f"got {actual_sha256}"
+ )
+ payload = _binding_payload(profile, resolved)
+ return PolicyCheckpointBinding(
+ schema_version=POLICY_CHECKPOINT_BINDING_SCHEMA_VERSION,
+ profile_id=profile.profile_id,
+ resolved_path=str(resolved),
+ files=profile.files,
+ binding_sha256=_canonical_sha256(payload),
+ )
+
+
+def write_policy_checkpoint_manifest(
+ destination: str | Path,
+ path: str | Path = SHARED_POLICY_CHECKPOINT_PATH,
+) -> PolicyCheckpointBinding:
+ """Validate the checkpoint and write the shared server manifest."""
+
+ binding = validate_policy_checkpoint(path)
+ target = Path(destination).expanduser()
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_text(
+ json.dumps(binding.as_dict(), sort_keys=True, indent=2) + "\n",
+ encoding="utf-8",
+ )
+ return binding
+
+
+def assert_matching_policy_checkpoint_binding(
+ actual: Mapping[str, Any] | None,
+ expected: PolicyCheckpointBinding | Mapping[str, Any],
+) -> dict[str, Any]:
+ expected_value = (
+ expected.as_dict()
+ if isinstance(expected, PolicyCheckpointBinding)
+ else dict(expected)
+ )
+ if not isinstance(actual, Mapping):
+ raise PolicyCheckpointError("VLA health metadata lacks checkpoint_binding")
+ actual_value = dict(actual)
+ if actual_value != expected_value:
+ raise PolicyCheckpointError(
+ "VLA checkpoint binding does not match your Pi05-Behavior model"
+ )
+ return actual_value
+
+
+__all__ = [
+ "POLICY_CHECKPOINT_BINDING_SCHEMA_VERSION",
+ "POLICY_CHECKPOINT_ENV",
+ "SHARED_POLICY_CHECKPOINT_PATH",
+ "SHARED_POLICY_PROFILE",
+ "SHARED_POLICY_PROFILE_ID",
+ "CheckpointFileRequirement",
+ "PolicyCheckpointBinding",
+ "PolicyCheckpointError",
+ "PolicyCheckpointProfile",
+ "assert_matching_policy_checkpoint_binding",
+ "validate_policy_checkpoint",
+ "write_policy_checkpoint_manifest",
+]
diff --git a/robots/behavior/prompt_bundle.py b/robots/behavior/prompt_bundle.py
new file mode 100644
index 000000000..37ad232bb
--- /dev/null
+++ b/robots/behavior/prompt_bundle.py
@@ -0,0 +1,46 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR prompt bundle assembly."""
+
+from __future__ import annotations
+
+from collections.abc import Mapping
+
+from robots.behavior.prompts import eval as eval_parts
+from robots.behavior.prompts import explore as explore_parts
+from robots.behavior.prompts import user as user_parts
+from rpent.prompt.utils import PromptNode
+
+
+def system_prompt(variables: Mapping[str, object] | None = None) -> PromptNode:
+ """Assemble the BEHAVIOR system prompt for the selected run mode."""
+ mode = (variables or {}).get("behavior_mode", "eval")
+ if mode == "eval":
+ return eval_parts.system_prompt()
+ if mode == "explore":
+ return explore_parts.system_prompt()
+ raise ValueError(f"unsupported BEHAVIOR prompt mode: {mode!r}")
+
+
+def user_prompt(variables: Mapping[str, object] | None = None) -> PromptNode:
+ """Assemble the BEHAVIOR user prompt tree."""
+ return {
+ "CELL": user_parts.CELL,
+ "MODE": user_parts.MODE,
+ "BEGIN": user_parts.BEGIN,
+ }
+
+
+__all__ = ["system_prompt", "user_prompt"]
diff --git a/robots/behavior/prompts/__init__.py b/robots/behavior/prompts/__init__.py
new file mode 100644
index 000000000..16fd5e775
--- /dev/null
+++ b/robots/behavior/prompts/__init__.py
@@ -0,0 +1,15 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Prompt sections for the BEHAVIOR robot plugin."""
diff --git a/robots/behavior/prompts/eval.py b/robots/behavior/prompts/eval.py
new file mode 100644
index 000000000..3d188b720
--- /dev/null
+++ b/robots/behavior/prompts/eval.py
@@ -0,0 +1,46 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR single-attempt evaluation prompt."""
+
+from __future__ import annotations
+
+from robots.behavior.prompts import system as base
+from rpent.prompt.utils import PromptNode
+
+ROLE_AND_EVALUATION = """You are the planner for one BEHAVIOR evaluation
+episode. The invocation controls only that episode. Pursue the exact task while
+remaining honest about official success."""
+
+MEMORY = """Memory access is read-only in Eval. Read only relevant material
+through the official MemoryManager tools; do not write to the corpus."""
+
+
+def system_prompt() -> PromptNode:
+ """Assemble the complete BEHAVIOR Eval prompt."""
+ return {
+ "ROLE AND EVALUATION": ROLE_AND_EVALUATION,
+ "CURRENT INVOCATION": base.CURRENT_INVOCATION,
+ "INVOCATION MODEL": base.INVOCATION_MODEL,
+ "RUNTIME": base.RUNTIME,
+ "YOUR GOAL": base.GOAL,
+ "MEMORY": f"{base.MEMORY_CONTEXT}\n\n{MEMORY}",
+ "EVIDENCE": base.EVIDENCE,
+ "PLANNER TOOLS": base.PLANNER_TOOLS,
+ "TERMINATION": base.TERMINATION,
+ "OUTPUT DISCIPLINE": base.OUTPUT_DISCIPLINE,
+ }
+
+
+__all__ = ["MEMORY", "ROLE_AND_EVALUATION", "system_prompt"]
diff --git a/robots/behavior/prompts/explore.py b/robots/behavior/prompts/explore.py
new file mode 100644
index 000000000..7b6cab4be
--- /dev/null
+++ b/robots/behavior/prompts/explore.py
@@ -0,0 +1,56 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR single-attempt exploration prompt."""
+
+from __future__ import annotations
+
+from robots.behavior.prompts import system as base
+from rpent.prompt.utils import PromptNode
+
+ROLE_AND_MODE = """You are the planner for one BEHAVIOR Explore attempt. This
+invocation owns exactly one episode. The standard RPent Explore session loop,
+not the planner, starts any later attempt."""
+
+MEMORY = """Write provisional memory only under `{{memory_inbox}}/wip/`.
+After current official success, write `{{output_dir}}/{{recipe_tag}}.json` as
+the task audit: task, seed, the verified success receipt, and the winning
+session's actual command sequence and strategy notes. This audit is a run
+output, not a memory-corpus write, and is separate from the terminal receipt.
+Do not claim commands or success absent from the
+current receipts. The runner exports `recipe_{{recipe_tag}}.jsonl` after finish.
+Only after official success may reusable notes be promoted to root-level
+Markdown drafts in `{{memory_inbox}}`; follow the existing MemoryManager
+frontmatter schema. Unsolved notes stay in wip and are not published.
+The main CLI merges the audit/recipe pair and valid memory drafts after Explore
+finishes when `--auto-merge-memory` is enabled. Do not invoke merge yourself."""
+
+
+def system_prompt() -> PromptNode:
+ """Assemble the complete BEHAVIOR Explore prompt."""
+ return {
+ "ROLE AND MODE": ROLE_AND_MODE,
+ "CURRENT INVOCATION": base.CURRENT_INVOCATION,
+ "INVOCATION MODEL": base.INVOCATION_MODEL,
+ "RUNTIME": base.RUNTIME,
+ "YOUR GOAL": base.GOAL,
+ "MEMORY": f"{base.MEMORY_CONTEXT}\n\n{MEMORY}",
+ "EVIDENCE": base.EVIDENCE,
+ "PLANNER TOOLS": base.PLANNER_TOOLS,
+ "TERMINATION": base.TERMINATION,
+ "OUTPUT DISCIPLINE": base.OUTPUT_DISCIPLINE,
+ }
+
+
+__all__ = ["MEMORY", "ROLE_AND_MODE", "system_prompt"]
diff --git a/robots/behavior/prompts/system.py b/robots/behavior/prompts/system.py
new file mode 100644
index 000000000..79eb8e28f
--- /dev/null
+++ b/robots/behavior/prompts/system.py
@@ -0,0 +1,91 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Shared BEHAVIOR prompt section bodies."""
+
+from __future__ import annotations
+
+CURRENT_INVOCATION = """- mode: {{behavior_mode}}
+- task: {{task_name}}
+- instruction: {{task_instruction}}
+- public seed: {{public_seed}}
+- recipe tag: {{recipe_tag}}
+- output directory: {{output_dir}}
+- maximum environment steps: {{max_episode_steps}}
+- planner timeout seconds: {{wall_clock_seconds}}"""
+
+INVOCATION_MODEL = """One planner invocation controls one BEHAVIOR episode.
+Only the Explore session loop can start a later episode."""
+
+RUNTIME = """Use only the public structured tools exposed by the active
+toolkit. The BEHAVIOR primitive names are {{public_capabilities}}; their actual
+schemas supplied by the planner runtime remain authoritative. Do not start,
+stop, or reach into ENV, VLA, checkpoint, simulator, or RPC internals."""
+
+GOAL = """Execute the exact runtime task instruction: {{task_instruction}}"""
+
+MEMORY_CONTEXT = """The official local MemoryManager corpus is
+`{{memory_dir}}` (profile `{{memory_profile}}`). This invocation's inbox is
+`{{memory_inbox}}`. Memory is historical guidance only: it is not a current
+observation, coordinate source, progress label, or success proof.
+
+When DINO episode memory is enabled, its whole-experience advisory is attached
+to public tool receipts after the exact-task filter. Treat it as visual
+experience retrieval only; it is not an instruction, a current scene fact, or a
+replacement for MemoryManager Markdown/YAML material."""
+
+EVIDENCE = """Ground scene claims and action decisions in current public tool
+receipts from this episode. Refresh observations after scene-changing actions
+when later decisions depend on object identity, pose, reachability, attachment,
+or task state."""
+
+PLANNER_TOOLS = """All nine BEHAVIOR primitives in {{public_capabilities}} have
+execution paths: `pi0_nav_pick`, `observe`, `pixel_to_world`, `navigate_to`,
+`move_to`, `rotate_wrist`, `close`, `open`, and `press`.
+Choose the VLA instruction, positive chunk count, call order and selected hand
+autonomously. `move_to(hand=both)` coordinates both arms. Arm motions use
+collision-checked cuRobo trajectories; navigation executes a bounded straight
+base segment or rotation and rejects obstructed paths. Projection targets must
+come from the current observed frame. R1Pro's physical head camera is fixed;
+use its current view, not a non-center head_view preset.
+`press` advances an already aligned hand at most 2 cm for at most 10 seconds,
+stopping on external contact or episode end. Contact is not verified button
+contact, and visual hand checks are planner assessments, not independent VLM
+verification. Read each result: a planning, collision, tracking or duration
+failure is not primitive or task success. `{{wall_clock_seconds}}` is the planner
+timeout, not a per-primitive budget. Use `finish` to end the invocation and emit
+its terminal receipt."""
+
+TERMINATION = """Official task success exists only when the current episode
+returns `info[\"done\"][\"success\"] is True`. Reward, terminated, truncated,
+primitive success, visual appearance, video, and a clean process exit cannot
+substitute for that value. A verified receipt may carry this evidence but cannot
+create it."""
+
+OUTPUT_DISCIPLINE = """Keep the final result tied to this invocation. Distinguish
+official task success from primitive progress, interruption, termination,
+truncation, and workflow completion. Never present historical memory as current
+evidence."""
+
+__all__ = [
+ "CURRENT_INVOCATION",
+ "EVIDENCE",
+ "GOAL",
+ "INVOCATION_MODEL",
+ "MEMORY_CONTEXT",
+ "OUTPUT_DISCIPLINE",
+ "PLANNER_TOOLS",
+ "RUNTIME",
+ "TERMINATION",
+]
diff --git a/robots/behavior/prompts/user.py b/robots/behavior/prompts/user.py
new file mode 100644
index 000000000..1acde3c0a
--- /dev/null
+++ b/robots/behavior/prompts/user.py
@@ -0,0 +1,35 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""User prompt sections for one BEHAVIOR invocation."""
+
+from __future__ import annotations
+
+CELL = """- task: {{task_name}}
+- seed: {{public_seed}}
+- instruction: {{task_instruction}}
+- output_dir: {{output_dir}}
+- audit: {{output_dir}}/{{recipe_tag}}.json
+- recipe: {{output_dir}}/recipe_{{recipe_tag}}.jsonl
+- memory: {{memory_dir}}"""
+
+MODE = """BEHAVIOR {{behavior_mode}}; one fresh episode per planner invocation;
+official success requires current `info[\"done\"][\"success\"] is True`.
+DINO episode memory, when enabled, appears only as advisory content in public
+tool receipts."""
+
+BEGIN = """Execute the selected task using the active public tools. Base each
+action on current public evidence and report the outcome honestly."""
+
+__all__ = ["BEGIN", "CELL", "MODE"]
diff --git a/robots/behavior/rlinf_env.py b/robots/behavior/rlinf_env.py
new file mode 100644
index 000000000..8c7c83d29
--- /dev/null
+++ b/robots/behavior/rlinf_env.py
@@ -0,0 +1,2024 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Bundled official BEHAVIOR backend for the RPent env RPC server.
+
+This module is intentionally independent from the historical RPent BEHAVIOR
+runtime helpers. It builds an RLinf ``BehaviorEnv`` config, owns the single
+live env instance, and exposes the narrow duck-typed surface consumed by
+``robots.behavior.env_server.BehaviorEnvFacade``.
+"""
+
+from __future__ import annotations
+
+import base64
+import hashlib
+import io
+import json
+import os
+import sys
+import time
+from collections.abc import Mapping
+from pathlib import Path
+from types import SimpleNamespace
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.schemas import ENV_ACTION_SEGMENTS, RAW_PROPRIO_SEGMENTS
+from robots.behavior.terminal_success import official_success_receipt_sha256
+from rpent.utils.config import get_repo_root, get_rlinf_repo_path
+
+ACTION_DIM = 23
+ACTION_HORIZON = 32
+GRIPPER_COMMAND_CONTROL_CYCLES = 15
+GRIPPER_OPEN_COMMAND = 1.0
+GRIPPER_CLOSE_COMMAND = -1.0
+PHYSICAL_CAMERAS = ("head", "left_wrist", "right_wrist")
+EXACT_OFFICIAL_CONFIG_MODE = "exact_official_v1"
+EXACT_OFFICIAL_RUNTIME_SUPPORT_SCHEMA = (
+ "rlinf.behavior.exact_official_runtime_support.v1"
+)
+EXACT_OFFICIAL_OVERLAY_SCHEMA = "rlinf.behavior.exact_official_overlay.v1"
+EXACT_OFFICIAL_WRAPPER_SELECTOR = "official_rgb_v1"
+RLINF_ROOT_ENV = "RPENT_RLINF_ROOT"
+RLINF_ENV_CONFIG_ENV = "RPENT_BEHAVIOR_RLINF_ENV_CONFIG"
+ACTIVITY_INSTANCE_DIR_ENV = "RPENT_BEHAVIOR_ACTIVITY_INSTANCE_DIR"
+ACTIVITY_INSTANCE_FORMAT_ENV = "RPENT_BEHAVIOR_ACTIVITY_INSTANCE_FORMAT"
+EXACT_CONFIG_ENV = "RPENT_BEHAVIOR_EXACT_OFFICIAL_CONFIG"
+RESET_TRACE_ENV = "RLINF_BEHAVIOR_RESET_TRACE"
+_COMPLETE_EXACT_FIELDS = {
+ "omni_config_mode",
+ "omni_config",
+ "omni_config_semantic_sha256",
+ "omni_config_runtime_support",
+ "omni_config_runtime_support_sha256",
+ "omni_config_effective_overlay",
+ "omni_config_effective_overlay_sha256",
+ "omni_config_effective_sha256",
+}
+
+
+def discover_rlinf_root() -> Path:
+ """Return the RLinf checkout that contains the official BehaviorEnv."""
+
+ root = (get_rlinf_repo_path() or (get_repo_root().parent / "RLinf")).resolve()
+ if (root / "rlinf" / "envs" / "behavior" / "behavior_env.py").is_file():
+ return root
+ raise FileNotFoundError(
+ "could not locate RLinf behavior_env.py; set "
+ f"{RLINF_ROOT_ENV} to the RLinf checkout. searched: {root}"
+ )
+
+
+def ensure_rlinf_import_path() -> Path:
+ """Put the selected RLinf checkout on sys.path and return it."""
+
+ root = discover_rlinf_root()
+ root_text = str(root)
+ if root_text not in sys.path:
+ sys.path.insert(0, root_text)
+ return root
+
+
+def _canonical_json_sha256(value: Any) -> str:
+ payload = json.dumps(
+ value,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=False,
+ allow_nan=False,
+ ).encode("utf-8")
+ return hashlib.sha256(payload).hexdigest()
+
+
+def _read_structured_file(path: Path) -> Any:
+ suffix = path.suffix.lower()
+ if suffix == ".json":
+ return json.loads(path.read_text(encoding="utf-8"))
+ from omegaconf import OmegaConf
+
+ cfg = OmegaConf.load(path)
+ return OmegaConf.to_container(cfg, resolve=True, throw_on_missing=True)
+
+
+def _coerce_positive_int(value: Any, *, field: str) -> int:
+ if isinstance(value, (bool, np.bool_)):
+ raise ValueError(f"{field} must be a positive integer")
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{field} must be a positive integer") from exc
+ if result <= 0:
+ raise ValueError(f"{field} must be a positive integer")
+ return result
+
+
+def _coerce_nonnegative_int(value: Any, *, field: str) -> int:
+ if isinstance(value, (bool, np.bool_)):
+ raise ValueError(f"{field} must be a non-negative integer")
+ try:
+ result = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{field} must be a non-negative integer") from exc
+ if result < 0:
+ raise ValueError(f"{field} must be a non-negative integer")
+ return result
+
+
+def _require_text(meta: Mapping[str, Any], field: str) -> str:
+ value = meta.get(field)
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"meta[{field!r}] must be a non-empty string")
+ return value.strip()
+
+
+def _task_identity(meta: Mapping[str, Any]) -> dict[str, Any]:
+ return {
+ "task_name": _require_text(meta, "task_name"),
+ "task_language": _require_text(meta, "task_language"),
+ "activity_definition_id": _coerce_nonnegative_int(
+ meta.get("activity_definition_id"),
+ field="activity_definition_id",
+ ),
+ "activity_instance_id": _coerce_nonnegative_int(
+ meta.get("activity_instance_id"),
+ field="activity_instance_id",
+ ),
+ "public_seed": _coerce_nonnegative_int(
+ meta.get("public_seed", 0),
+ field="public_seed",
+ ),
+ "scene_model": _require_text(meta, "scene_model"),
+ "max_episode_steps": _coerce_positive_int(
+ meta.get("max_episode_steps"),
+ field="max_episode_steps",
+ ),
+ }
+
+
+def _resolution(value: Any, default: tuple[int, int]) -> list[int]:
+ if value is None:
+ return [int(default[0]), int(default[1])]
+ if not isinstance(value, (list, tuple)) or len(value) != 2:
+ raise ValueError("camera resolution must contain two positive integers")
+ return [
+ _coerce_positive_int(value[0], field="camera resolution"),
+ _coerce_positive_int(value[1], field="camera resolution"),
+ ]
+
+
+def _exact_runtime_support(
+ *,
+ official: Mapping[str, Any],
+ meta: Mapping[str, Any],
+) -> dict[str, Any]:
+ camera_cfg = (
+ official.get("camera") if isinstance(official.get("camera"), Mapping) else {}
+ )
+ return {
+ "schema_version": EXACT_OFFICIAL_RUNTIME_SUPPORT_SCHEMA,
+ "source_profile_sha256": _canonical_json_sha256(official),
+ "wrapper_selector": EXACT_OFFICIAL_WRAPPER_SELECTOR,
+ "macro": {
+ "use_gpu_dynamics": bool(meta.get("use_gpu_dynamics", False)),
+ "headless": bool(meta.get("headless", True)),
+ "enable_flatcache": bool(meta.get("enable_flatcache", True)),
+ "enable_object_states": bool(meta.get("enable_object_states", True)),
+ "enable_transition_rules": bool(meta.get("enable_transition_rules", True)),
+ "render_viewer_camera": bool(meta.get("render_viewer_camera", False)),
+ "use_numpy_controller_backend": bool(
+ meta.get("use_numpy_controller_backend", True)
+ ),
+ },
+ "camera": {
+ "head_resolution": _resolution(
+ meta.get("head_resolution", camera_cfg.get("head_resolution")),
+ (720, 720),
+ ),
+ "wrist_resolution": _resolution(
+ meta.get("wrist_resolution", camera_cfg.get("wrist_resolution")),
+ (480, 480),
+ ),
+ },
+ }
+
+
+def _exact_overlay(
+ official: Mapping[str, Any],
+) -> tuple[dict[str, Any], dict[str, Any]]:
+ official_copy = json.loads(
+ json.dumps(official, ensure_ascii=False, allow_nan=False)
+ )
+ env_cfg = official_copy.setdefault("env", {})
+ task_cfg = official_copy.setdefault("task", {})
+ termination = task_cfg.setdefault("termination_config", {})
+ source_max_steps = _coerce_positive_int(
+ termination.get("max_steps"),
+ field="task.termination_config.max_steps",
+ )
+ flatten_source = env_cfg.get("flatten_obs_space")
+ if flatten_source is not True:
+ raise ValueError(
+ "exact official omni_config.env.flatten_obs_space must be True "
+ "so RLinf can apply its reviewed runtime overlay"
+ )
+ overlay = {
+ "schema_version": EXACT_OFFICIAL_OVERLAY_SCHEMA,
+ "changes": {
+ "env.flatten_obs_space": {"source": True, "effective": False},
+ "task.termination_config.max_steps": {
+ "source": source_max_steps,
+ "effective": source_max_steps - 1,
+ },
+ },
+ }
+ effective = json.loads(
+ json.dumps(official_copy, ensure_ascii=False, allow_nan=False)
+ )
+ effective["env"]["flatten_obs_space"] = False
+ effective["task"]["termination_config"]["max_steps"] = source_max_steps - 1
+ return overlay, effective
+
+
+def _assert_official_identity(
+ official: Mapping[str, Any],
+ meta: Mapping[str, Any],
+) -> None:
+ identity = _task_identity(meta)
+ task_cfg = official.get("task")
+ scene_cfg = official.get("scene")
+ if not isinstance(task_cfg, Mapping) or not isinstance(scene_cfg, Mapping):
+ raise ValueError("exact official config must contain task and scene mappings")
+ mismatches = {
+ "task.activity_name": (
+ identity["task_name"],
+ task_cfg.get("activity_name"),
+ ),
+ "task.activity_definition_id": (
+ identity["activity_definition_id"],
+ task_cfg.get("activity_definition_id"),
+ ),
+ "task.activity_instance_id": (
+ identity["activity_instance_id"],
+ task_cfg.get("activity_instance_id"),
+ ),
+ "scene.scene_model": (
+ identity["scene_model"],
+ scene_cfg.get("scene_model"),
+ ),
+ }
+ bad = {
+ key: {"expected": expected, "actual": actual}
+ for key, (expected, actual) in mismatches.items()
+ if actual != expected
+ }
+ if bad:
+ raise ValueError(f"exact official config identity mismatch: {bad}")
+
+
+def _exact_config_from_official(
+ official: Mapping[str, Any],
+ meta: Mapping[str, Any],
+ output_dir: Path,
+) -> dict[str, Any]:
+ official_dict = json.loads(
+ json.dumps(official, ensure_ascii=False, allow_nan=False)
+ )
+ identity = _task_identity(meta)
+ _assert_official_identity(official_dict, meta)
+ support = dict(
+ meta.get("omni_config_runtime_support") or {}
+ ) or _exact_runtime_support(
+ official=official_dict,
+ meta=meta,
+ )
+ overlay = meta.get("omni_config_effective_overlay")
+ effective: dict[str, Any] | None = None
+ if isinstance(overlay, Mapping):
+ effective = None
+ overlay = json.loads(json.dumps(overlay, ensure_ascii=False, allow_nan=False))
+ else:
+ overlay, effective = _exact_overlay(official_dict)
+ if effective is None:
+ effective = json.loads(
+ json.dumps(official_dict, ensure_ascii=False, allow_nan=False)
+ )
+ changes = dict(overlay["changes"])
+ effective["env"]["flatten_obs_space"] = changes["env.flatten_obs_space"][
+ "effective"
+ ]
+ effective["task"]["termination_config"]["max_steps"] = changes[
+ "task.termination_config.max_steps"
+ ]["effective"]
+ return {
+ "env_type": "behavior",
+ "total_num_envs": 1,
+ "auto_reset": False,
+ "ignore_terminations": False,
+ "use_rel_reward": True,
+ "seed": identity["public_seed"],
+ "group_size": 1,
+ "use_fixed_reset_state_ids": False,
+ "max_steps_per_rollout_epoch": identity["max_episode_steps"],
+ "max_episode_steps": identity["max_episode_steps"],
+ "skip_intermediate_obs_in_chunk": True,
+ "num_env_subprocess": 1,
+ "direct_omnigibson_env": True,
+ "video_cfg": {
+ "save_video": False,
+ "info_on_video": True,
+ "video_base_dir": str(output_dir / "video"),
+ },
+ "base_config_name": "r1pro_behavior",
+ "use_eval_utils_cfg": False,
+ "policy_wrapper": None,
+ "omni_config_mode": EXACT_OFFICIAL_CONFIG_MODE,
+ "omni_config": official_dict,
+ "omni_config_semantic_sha256": str(
+ meta.get("omni_config_semantic_sha256")
+ or _canonical_json_sha256(official_dict)
+ ),
+ "omni_config_runtime_support": support,
+ "omni_config_runtime_support_sha256": str(
+ meta.get("omni_config_runtime_support_sha256")
+ or _canonical_json_sha256(support)
+ ),
+ "omni_config_effective_overlay": overlay,
+ "omni_config_effective_overlay_sha256": str(
+ meta.get("omni_config_effective_overlay_sha256")
+ or _canonical_json_sha256(overlay)
+ ),
+ "omni_config_effective_sha256": str(
+ meta.get("omni_config_effective_sha256")
+ or _canonical_json_sha256(effective)
+ ),
+ "action_trace_path": str(output_dir / "behavior_action_trace.jsonl"),
+ "action_trace_interval": 1,
+ }
+
+
+def _load_exact_official_config(meta: Mapping[str, Any]) -> Mapping[str, Any] | None:
+ if meta.get("omni_config_mode") == EXACT_OFFICIAL_CONFIG_MODE and isinstance(
+ meta.get("omni_config"),
+ Mapping,
+ ):
+ return meta
+
+ path_value = (
+ meta.get("exact_official_config_path")
+ or meta.get("official_omni_config_path")
+ or os.environ.get(EXACT_CONFIG_ENV)
+ )
+ if not path_value:
+ return None
+ loaded = _read_structured_file(Path(str(path_value)).expanduser().resolve())
+ if not isinstance(loaded, Mapping):
+ raise ValueError("exact official config file must contain a mapping")
+ return loaded
+
+
+def _default_env_config_path(rlinf_root: Path, meta: Mapping[str, Any]) -> Path:
+ path_value = meta.get("rlinf_env_config_path") or os.environ.get(
+ RLINF_ENV_CONFIG_ENV
+ )
+ if path_value:
+ return Path(str(path_value)).expanduser().resolve()
+ return (
+ rlinf_root
+ / "examples"
+ / "embodiment"
+ / "config"
+ / "env"
+ / "behavior_r1pro.yaml"
+ )
+
+
+def _bootstrap_template_path(
+ instance_dir: Path,
+ *,
+ scene_model: str,
+ task_name: str,
+ activity_definition_id: int,
+) -> Path:
+ """Resolve the full instance-0 scene used before applying a TRO delta.
+
+ RLinf's ``ActivityInstanceLoader`` applies ``*_template-tro_state.json``
+ only immediately before reset. OmniGibson therefore needs a complete
+ same-task template to construct the object scope first. The official
+ challenge dataset stores that bootstrap template beside the task-specific
+ ``*_instances`` directory.
+ """
+
+ template_name = (
+ f"{scene_model}_task_{task_name}_{activity_definition_id}_0_template.json"
+ )
+ candidates = (instance_dir / template_name, instance_dir.parent / template_name)
+ template_path = next((path for path in candidates if path.is_file()), None)
+ if template_path is None:
+ raise FileNotFoundError(
+ "BEHAVIOR bootstrap scene template not found: "
+ + " or ".join(str(path) for path in candidates)
+ )
+ return template_path
+
+
+def _resolve_activity_instance_dir(
+ activity_dir: Path,
+ *,
+ scene_model: str,
+ task_name: str,
+ activity_definition_id: int,
+ activity_instance_id: int,
+) -> Path:
+ """Resolve a task instance directory from either supported CLI shape.
+
+ The public launcher accepts the downloaded challenge dataset root, while
+ RLinf's loader requires the task-specific directory that directly contains
+ the cached instance JSON files. An already task-specific directory remains
+ valid for callers that supply one explicitly.
+ """
+
+ instance_name = (
+ f"{scene_model}_task_{task_name}_{activity_definition_id}_"
+ f"{activity_instance_id}_template-tro_state.json"
+ )
+ task_dir_name = f"{scene_model}_task_{task_name}_instances"
+ candidates = (
+ activity_dir,
+ activity_dir / task_dir_name,
+ activity_dir / "scenes" / scene_model / "json" / task_dir_name,
+ )
+ instance_dir = next(
+ (path for path in candidates if (path / instance_name).is_file()),
+ None,
+ )
+ if instance_dir is None:
+ raise FileNotFoundError(
+ "BEHAVIOR activity instance not found: "
+ + " or ".join(str(path / instance_name) for path in candidates)
+ )
+ return instance_dir
+
+
+def _apply_default_config_identity(
+ cfg: Any,
+ *,
+ identity: Mapping[str, Any],
+ output_dir: Path,
+ meta: Mapping[str, Any],
+) -> Any:
+ from omegaconf import OmegaConf
+
+ cfg.env_type = "behavior"
+ cfg.total_num_envs = 1
+ cfg.auto_reset = False
+ cfg.ignore_terminations = False
+ cfg.use_fixed_reset_state_ids = False
+ cfg.seed = int(identity["public_seed"])
+ cfg.direct_omnigibson_env = True
+ cfg.num_env_subprocess = 1
+ cfg.max_episode_steps = int(identity["max_episode_steps"])
+ cfg.max_steps_per_rollout_epoch = int(identity["max_episode_steps"])
+ cfg.skip_intermediate_obs_in_chunk = True
+ cfg.video_cfg.save_video = False
+ cfg.video_cfg.video_base_dir = str(output_dir / "video")
+ cfg.omni_config.env.env_wrapper = str(meta.get("env_wrapper") or "rgb")
+ cfg.omni_config.env.flatten_obs_space = False
+ cfg.omni_config.env.flatten_action_space = False
+ cfg.omni_config.env.automatic_reset = False
+ cfg.omni_config.task.activity_name = str(identity["task_name"])
+ cfg.omni_config.task.activity_definition_id = int(
+ identity["activity_definition_id"]
+ )
+ cfg.omni_config.task.activity_instance_id = int(identity["activity_instance_id"])
+ cfg.omni_config.task.online_object_sampling = False
+ cfg.omni_config.task.termination_config.max_steps = int(
+ identity["max_episode_steps"]
+ )
+ cfg.omni_config.scene.scene_model = str(identity["scene_model"])
+
+ activity_dir = meta.get("activity_instance_dir") or os.environ.get(
+ ACTIVITY_INSTANCE_DIR_ENV
+ )
+ if activity_dir:
+ instance_dir = _resolve_activity_instance_dir(
+ Path(str(activity_dir)).expanduser().resolve(),
+ scene_model=str(identity["scene_model"]),
+ task_name=str(identity["task_name"]),
+ activity_definition_id=int(identity["activity_definition_id"]),
+ activity_instance_id=int(identity["activity_instance_id"]),
+ )
+ cfg.omni_config.task.activity_instance_dir = str(instance_dir)
+ cfg.omni_config.task.instance_resample_mode = "disabled"
+ instance_file_format = str(
+ meta.get("activity_instance_file_format")
+ or os.environ.get(ACTIVITY_INSTANCE_FORMAT_ENV)
+ or "tro_state"
+ )
+ cfg.omni_config.task.instance_file_format = instance_file_format
+ cfg.omni_config.task.use_presampled_robot_pose = bool(
+ meta.get("use_presampled_robot_pose", True)
+ )
+ if instance_file_format == "tro_state":
+ # A TRO-state file is a delta, not an OmniGibson scene template.
+ # Bootstrap the same task's object scope from the official full
+ # instance-0 template; ActivityInstanceLoader applies the selected
+ # native instance immediately before the first reset.
+ cfg.omni_config.scene.scene_file = str(
+ _bootstrap_template_path(
+ instance_dir,
+ scene_model=str(identity["scene_model"]),
+ task_name=str(identity["task_name"]),
+ activity_definition_id=int(identity["activity_definition_id"]),
+ )
+ )
+ cfg.omni_config.scene.scene_instance = None
+
+ for key, default in (
+ ("head_resolution", (720, 720)),
+ ("wrist_resolution", (480, 480)),
+ ):
+ if meta.get(key) is not None:
+ OmegaConf.update(
+ cfg,
+ f"omni_config.camera.{key}",
+ _resolution(meta.get(key), default),
+ merge=False,
+ )
+
+ cfg.action_trace_path = str(output_dir / "behavior_action_trace.jsonl")
+ cfg.action_trace_interval = 1
+ return cfg
+
+
+def build_behavior_env_config(meta: Mapping[str, Any], output_dir: str | Path) -> Any:
+ """Build the RLinf BehaviorEnv config without launching simulation.
+
+ If an exact official config is supplied through ``meta`` or
+ ``RPENT_BEHAVIOR_EXACT_OFFICIAL_CONFIG``, this returns an
+ ``exact_official_v1`` RLinf config. Otherwise it loads RLinf's canonical
+ ``behavior_r1pro.yaml`` and applies the task/instance identity from RPent.
+ """
+
+ from omegaconf import OmegaConf
+
+ output_path = Path(output_dir).expanduser().resolve()
+ identity = _task_identity(meta)
+ exact_loaded = _load_exact_official_config(meta)
+ if exact_loaded is not None:
+ if exact_loaded.get(
+ "omni_config_mode"
+ ) == EXACT_OFFICIAL_CONFIG_MODE and _COMPLETE_EXACT_FIELDS.issubset(
+ exact_loaded
+ ):
+ cfg_dict = dict(exact_loaded)
+ official = cfg_dict.get("omni_config")
+ if not isinstance(official, Mapping):
+ raise ValueError("exact official omni_config must be a mapping")
+ _assert_official_identity(official, meta)
+ cfg_dict.setdefault("seed", identity["public_seed"])
+ cfg_dict.setdefault("max_episode_steps", identity["max_episode_steps"])
+ cfg_dict.setdefault(
+ "max_steps_per_rollout_epoch",
+ identity["max_episode_steps"],
+ )
+ cfg_dict.setdefault("auto_reset", False)
+ cfg_dict.setdefault("ignore_terminations", False)
+ cfg_dict.setdefault("use_fixed_reset_state_ids", False)
+ cfg_dict.setdefault("skip_intermediate_obs_in_chunk", True)
+ cfg_dict.setdefault("num_env_subprocess", 1)
+ cfg_dict.setdefault("direct_omnigibson_env", True)
+ cfg_dict.setdefault(
+ "video_cfg",
+ {
+ "save_video": False,
+ "info_on_video": True,
+ "video_base_dir": str(output_path / "video"),
+ },
+ )
+ cfg_dict.setdefault(
+ "action_trace_path",
+ str(output_path / "behavior_action_trace.jsonl"),
+ )
+ cfg_dict.setdefault("action_trace_interval", 1)
+ return OmegaConf.create(cfg_dict)
+
+ official = exact_loaded.get("omni_config", exact_loaded)
+ if not isinstance(official, Mapping):
+ raise ValueError("exact official omni_config must be a mapping")
+ return OmegaConf.create(
+ _exact_config_from_official(official, meta, output_path)
+ )
+
+ rlinf_root = ensure_rlinf_import_path()
+ config_path = _default_env_config_path(rlinf_root, meta)
+ if not config_path.is_file():
+ raise FileNotFoundError(f"RLinf BEHAVIOR env config not found: {config_path}")
+ cfg = OmegaConf.load(config_path)
+ return _apply_default_config_identity(
+ cfg,
+ identity=identity,
+ output_dir=output_path,
+ meta=meta,
+ )
+
+
+def _torch_to_numpy(value: Any) -> Any:
+ if hasattr(value, "detach") and hasattr(value, "cpu") and hasattr(value, "numpy"):
+ return value.detach().cpu().numpy()
+ return value
+
+
+def _jsonable(value: Any) -> Any:
+ value = _torch_to_numpy(value)
+ if isinstance(value, np.ndarray):
+ return value
+ if isinstance(value, np.generic):
+ return value.item()
+ if isinstance(value, Mapping):
+ return {str(key): _jsonable(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_jsonable(item) for item in value]
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ return value
+ return repr(value)
+
+
+def _strict_public_json(value: Any) -> Any:
+ value = _torch_to_numpy(value)
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, np.generic):
+ return value.item()
+ if isinstance(value, Mapping):
+ return {str(key): _strict_public_json(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_strict_public_json(item) for item in value]
+ if isinstance(value, bytes):
+ return {
+ "format": "png",
+ "data": base64.b64encode(value).decode("ascii"),
+ }
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ return value
+ return repr(value)
+
+
+def _reset_trace_enabled() -> bool:
+ return os.environ.get(RESET_TRACE_ENV) == "1"
+
+
+def _emit_reset_trace_marker(
+ event: str,
+ *,
+ elapsed_s: float | None = None,
+ **fields: Any,
+) -> None:
+ if not _reset_trace_enabled():
+ return
+ payload = {
+ "schema_version": 1,
+ "component": "OfficialBehaviorBackend",
+ "event": event,
+ **fields,
+ }
+ if elapsed_s is not None:
+ payload["elapsed_s"] = float(elapsed_s)
+ try:
+ print(
+ json.dumps(
+ _strict_public_json(payload),
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=True,
+ allow_nan=False,
+ ),
+ flush=True,
+ )
+ except Exception:
+ # Tracing must never change reset / timeout / exception semantics.
+ pass
+
+
+def _image_uint8(value: Any) -> np.ndarray:
+ arr = np.asarray(_torch_to_numpy(value))
+ if arr.ndim != 3 or arr.shape[-1] not in {3, 4}:
+ raise ValueError(f"image must be [H,W,3 or 4], got {arr.shape}")
+ arr = arr[..., :3]
+ if arr.dtype == np.uint8:
+ return np.ascontiguousarray(arr)
+ if np.issubdtype(arr.dtype, np.floating):
+ max_value = float(np.nanmax(arr)) if arr.size else 1.0
+ if max_value <= 1.0 + 1e-6:
+ arr = arr * 255.0
+ arr = np.rint(arr).clip(0, 255).astype(np.uint8)
+ return np.ascontiguousarray(arr)
+
+
+def _first_batch(value: Any) -> Any:
+ arr = np.asarray(_torch_to_numpy(value))
+ if arr.ndim >= 1 and arr.shape[0] == 1:
+ return arr[0]
+ return arr
+
+
+def _task_description(value: Any, default: str) -> str:
+ value = _jsonable(value)
+ if isinstance(value, (list, tuple)):
+ for item in value:
+ if isinstance(item, str) and item.strip():
+ return item.strip()
+ return default
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return default
+
+
+def _extract_raw_observation(raw_obs: Mapping[str, Any]) -> dict[str, Any]:
+ main_image = None
+ left_image = None
+ right_image = None
+ proprio = None
+ for sensor_data in raw_obs.values():
+ if not isinstance(sensor_data, Mapping):
+ continue
+ for key, value in sensor_data.items():
+ if not isinstance(key, str):
+ continue
+ if "proprio" in key:
+ # RLinf exposes proprio as a tensor / ndarray, whereas camera
+ # observations are mappings containing an ``rgb`` value.
+ proprio = value
+ elif not isinstance(value, Mapping):
+ continue
+ elif "left_realsense_link:Camera:0" in key and "rgb" in value:
+ left_image = value["rgb"]
+ elif "right_realsense_link:Camera:0" in key and "rgb" in value:
+ right_image = value["rgb"]
+ elif "zed_link:Camera:0" in key and "rgb" in value:
+ main_image = value["rgb"]
+ if (
+ main_image is None
+ or left_image is None
+ or right_image is None
+ or proprio is None
+ ):
+ raise ValueError("raw BEHAVIOR observation lacks main/wrist RGB or proprio")
+ return {
+ "main_images": main_image,
+ "wrist_images": np.stack(
+ [_image_uint8(left_image), _image_uint8(right_image)],
+ axis=0,
+ ),
+ "states": proprio,
+ }
+
+
+def _normalize_single_observation(
+ obs: Mapping[str, Any], *, task_language: str
+) -> dict[str, Any]:
+ if "main_images" not in obs or "wrist_images" not in obs or "states" not in obs:
+ obs = _extract_raw_observation(obs)
+
+ main = _image_uint8(_first_batch(obs["main_images"]))
+ wrists_value = _first_batch(obs["wrist_images"])
+ wrists = np.asarray(_torch_to_numpy(wrists_value))
+ if wrists.ndim == 5 and wrists.shape[0] == 1:
+ wrists = wrists[0]
+ if wrists.ndim != 4 or wrists.shape[0] != 2:
+ raise ValueError(f"wrist_images must be [2,H,W,3], got {wrists.shape}")
+ left = _image_uint8(wrists[0])
+ right = _image_uint8(wrists[1])
+ states = np.asarray(_first_batch(obs["states"]), dtype=np.float32)
+ if states.ndim != 1:
+ raise ValueError(f"states must be [raw_proprio_dim], got {states.shape}")
+ if not np.isfinite(states).all():
+ raise ValueError("states contains NaN or infinity")
+ return {
+ "main_images": main,
+ "wrist_images": np.ascontiguousarray(np.stack([left, right], axis=0)),
+ "states": np.ascontiguousarray(states.astype(np.float32, copy=False)),
+ "task_descriptions": _task_description(
+ obs.get("task_descriptions"),
+ task_language,
+ ),
+ "extra_view_images": None,
+ }
+
+
+def _validate_action_chunk(actions: Any) -> np.ndarray:
+ arr = np.asarray(actions, dtype=np.float32)
+ if arr.ndim != 2 or arr.shape[1] != ACTION_DIM or arr.shape[0] < 1:
+ raise ValueError(f"BEHAVIOR actions must be [T,{ACTION_DIM}], got {arr.shape}")
+ if not np.isfinite(arr).all():
+ raise ValueError("BEHAVIOR actions contain NaN or infinity")
+ return np.ascontiguousarray(arr)
+
+
+def _raw_success(info: Any) -> bool:
+ done = info.get("done") if isinstance(info, Mapping) else None
+ value = done.get("success") if isinstance(done, Mapping) else None
+ return isinstance(value, (bool, np.bool_)) and bool(value)
+
+
+def _receipt_from_info(
+ info: Mapping[str, Any], *, env_step: int
+) -> dict[str, Any] | None:
+ if not _raw_success(info) or type(env_step) is not int or env_step < 0:
+ return None
+ material = {
+ "schema_version": 1,
+ "source": 'info["done"]["success"]',
+ "env_step": env_step,
+ "raw_done": {"success": True},
+ }
+ return {
+ **material,
+ "receipt_sha256": official_success_receipt_sha256(material),
+ }
+
+
+def _png_bytes(image: np.ndarray) -> bytes:
+ import imageio.v2 as imageio
+
+ buf = io.BytesIO()
+ imageio.imwrite(buf, _image_uint8(image), format="png")
+ return buf.getvalue()
+
+
+def _write_frame_files(
+ frames: Mapping[str, bytes],
+ *,
+ output_dir: Path,
+ group_id: str,
+) -> dict[str, str]:
+ capture_dir = output_dir / "captures"
+ capture_dir.mkdir(parents=True, exist_ok=True)
+ paths: dict[str, str] = {}
+ for camera, payload in frames.items():
+ filename = f"{group_id}_{camera}.png"
+ path = capture_dir / filename
+ path.write_bytes(payload)
+ try:
+ paths[str(camera)] = str(path.relative_to(output_dir))
+ except ValueError:
+ paths[str(camera)] = str(path)
+ return paths
+
+
+class OfficialBehaviorBackend:
+ """Duck-typed backend around one official RLinf BehaviorEnv."""
+
+ def __init__(
+ self,
+ *,
+ meta: Mapping[str, Any],
+ output_dir: str | Path,
+ behavior_env_cls: Any | None = None,
+ cfg: Any | None = None,
+ ) -> None:
+ self.meta = dict(meta)
+ self.identity = _task_identity(self.meta)
+ self.output_dir = Path(output_dir).expanduser().resolve()
+ self.output_dir.mkdir(parents=True, exist_ok=True)
+ self._last_obs: dict[str, Any] | None = None
+ self._last_info: dict[str, Any] = {}
+ self._last_raw_obs: Any = None
+ self._closed = False
+ self._episode_ended = False
+ self._total_env_steps = 0
+ self._official_success_latched = False
+ self._official_success_receipt: dict[str, Any] | None = None
+ self._camera_frames: dict[str, Any] = {}
+ self._camera_frame_step = -1
+ self._projections: dict[str, Any] = {}
+ self._motion_planner = None
+ self._gripper_latch = {
+ "left": GRIPPER_OPEN_COMMAND,
+ "right": GRIPPER_OPEN_COMMAND,
+ }
+ self.cfg = (
+ cfg
+ if cfg is not None
+ else build_behavior_env_config(self.meta, self.output_dir)
+ )
+ if behavior_env_cls is None:
+ ensure_rlinf_import_path()
+ from rlinf.envs.behavior.behavior_env import BehaviorEnv
+
+ behavior_env_cls = BehaviorEnv
+ self._env = behavior_env_cls(
+ self.cfg,
+ num_envs=1,
+ seed_offset=0,
+ total_num_processes=1,
+ worker_info=SimpleNamespace(group_world_size=1),
+ record_metrics=False,
+ )
+
+ @property
+ def total_env_steps(self) -> int:
+ return self._total_env_steps
+
+ @property
+ def official_success_latched(self) -> bool:
+ return self._official_success_latched
+
+ @property
+ def official_success_receipt(self) -> dict[str, Any] | None:
+ if self._official_success_receipt is None:
+ return None
+ return dict(self._official_success_receipt)
+
+ def _wrap_raw_obs(self, raw_obs: Any) -> dict[str, Any]:
+ if isinstance(raw_obs, Mapping) and {
+ "main_images",
+ "wrist_images",
+ "states",
+ }.issubset(raw_obs):
+ return _normalize_single_observation(
+ raw_obs,
+ task_language=self.identity["task_language"],
+ )
+ wrapper = getattr(self._env, "_wrap_obs", None)
+ if callable(wrapper):
+ try:
+ wrapped = wrapper([raw_obs])
+ return _normalize_single_observation(
+ wrapped,
+ task_language=self.identity["task_language"],
+ )
+ except Exception:
+ pass
+ if isinstance(raw_obs, Mapping):
+ return _normalize_single_observation(
+ raw_obs,
+ task_language=self.identity["task_language"],
+ )
+ raise TypeError("BEHAVIOR raw observation is not a mapping")
+
+ def _note_info(
+ self,
+ info: Any,
+ *,
+ telemetry: Mapping[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ info_dict = dict(_jsonable(info)) if isinstance(info, Mapping) else {}
+ runtime = info_dict.get("_rpent")
+ if not isinstance(runtime, dict):
+ runtime = {}
+ runtime["total_env_steps"] = int(self._total_env_steps)
+ runtime["global_env_steps"] = int(self._total_env_steps)
+ if telemetry is not None:
+ for field in (
+ "executed_steps",
+ "stop_reason",
+ "success_step_in_chunk",
+ ):
+ value = telemetry.get(field)
+ if value is not None:
+ info_dict[field] = _strict_public_json(value)
+ if _raw_success(info_dict):
+ self._official_success_latched = True
+ receipt = _receipt_from_info(info_dict, env_step=self._total_env_steps)
+ if receipt is not None:
+ self._official_success_receipt = receipt
+ runtime["official_success_receipt"] = dict(receipt)
+ info_dict["_rpent"] = runtime
+ self._last_info = info_dict
+ return info_dict
+
+ def _reset_raw(self) -> tuple[Any, dict[str, Any]]:
+ reset_raw = getattr(self._env, "reset_raw", None)
+ env_reset = getattr(self._env, "env_reset", None)
+ if callable(reset_raw):
+ branch = "reset_raw"
+ elif callable(env_reset):
+ branch = "env_reset"
+ else:
+ branch = "reset_fallback"
+ started_at = time.monotonic()
+ _emit_reset_trace_marker(
+ "official_behavior_backend._reset_raw.enter",
+ branch=branch,
+ )
+ try:
+ if callable(reset_raw):
+ obs, info = reset_raw(env_idx=0)
+ elif callable(env_reset):
+ observations, infos = env_reset()
+ if not isinstance(observations, (list, tuple)) or not observations:
+ raise TypeError(
+ "RLinf BehaviorEnv.env_reset returned no observations"
+ )
+ obs = observations[0]
+ info = infos[0] if isinstance(infos, (list, tuple)) and infos else {}
+ else:
+ ret = self._env.reset()
+ if isinstance(ret, (tuple, list)) and len(ret) == 2:
+ obs, info = ret
+ else:
+ obs, info = ret, {}
+ info_out = dict(_jsonable(info)) if isinstance(info, Mapping) else {}
+ except Exception as exc:
+ _emit_reset_trace_marker(
+ "official_behavior_backend._reset_raw.exit",
+ branch=branch,
+ status="error",
+ error_type=type(exc).__name__,
+ error=str(exc),
+ elapsed_s=time.monotonic() - started_at,
+ )
+ raise
+ _emit_reset_trace_marker(
+ "official_behavior_backend._reset_raw.exit",
+ branch=branch,
+ status="ok",
+ info_is_mapping=isinstance(info, Mapping),
+ observation_type=type(obs).__name__,
+ elapsed_s=time.monotonic() - started_at,
+ )
+ return obs, info_out
+
+ def _step_one_raw(
+ self, action: np.ndarray
+ ) -> tuple[Any, float, bool, bool, dict[str, Any]]:
+ step_raw = getattr(self._env, "step_raw", None)
+ if callable(step_raw):
+ obs, reward, terminated, truncated, info = step_raw(action, env_idx=0)
+ return (
+ obs,
+ float(np.asarray(_torch_to_numpy(reward)).reshape(-1)[0]),
+ bool(np.asarray(_torch_to_numpy(terminated)).reshape(-1)[0]),
+ bool(np.asarray(_torch_to_numpy(truncated)).reshape(-1)[0]),
+ dict(_jsonable(info)) if isinstance(info, Mapping) else {},
+ )
+
+ env_chunk_step = getattr(self._env, "env_chunk_step", None)
+ if callable(env_chunk_step):
+ import torch
+
+ chunk_action = torch.as_tensor(
+ action.reshape(1, 1, ACTION_DIM), dtype=torch.float32
+ )
+ raw_obs_list, rewards, terms, truncs, infos = env_chunk_step(chunk_action)
+ obs = raw_obs_list[-1][0] if raw_obs_list[-1] is not None else None
+ info = infos[-1][0] if infos[-1] else {}
+ return (
+ obs,
+ float(np.asarray(_torch_to_numpy(rewards[-1])).reshape(-1)[0]),
+ bool(np.asarray(_torch_to_numpy(terms[-1])).reshape(-1)[0]),
+ bool(np.asarray(_torch_to_numpy(truncs[-1])).reshape(-1)[0]),
+ dict(_jsonable(info)) if isinstance(info, Mapping) else {},
+ )
+
+ chunk_step = getattr(self._env, "chunk_step", None)
+ if callable(chunk_step):
+ obs_list, rewards, terms, truncs, infos = chunk_step(
+ action.reshape(1, 1, ACTION_DIM)
+ )
+ obs = obs_list[-1] if isinstance(obs_list, (list, tuple)) else obs_list
+ info = infos[-1] if isinstance(infos, (list, tuple)) and infos else {}
+ if isinstance(info, list) and info:
+ info = info[0]
+ return (
+ obs,
+ float(np.asarray(_torch_to_numpy(rewards)).reshape(-1)[-1]),
+ bool(np.asarray(_torch_to_numpy(terms)).reshape(-1)[-1]),
+ bool(np.asarray(_torch_to_numpy(truncs)).reshape(-1)[-1]),
+ dict(_jsonable(info)) if isinstance(info, Mapping) else {},
+ )
+
+ raise RuntimeError("RLinf BehaviorEnv exposes no raw step interface")
+
+ def reset(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ started_at = time.monotonic()
+ _emit_reset_trace_marker(
+ "official_behavior_backend.reset.enter",
+ total_env_steps_before=int(self._total_env_steps),
+ )
+ try:
+ self._total_env_steps = 0
+ self._episode_ended = False
+ self._camera_frames = {}
+ self._camera_frame_step = -1
+ self._projections = {}
+ self._gripper_latch = {
+ "left": GRIPPER_OPEN_COMMAND,
+ "right": GRIPPER_OPEN_COMMAND,
+ }
+ raw_obs, info = self._reset_raw()
+ self._last_raw_obs = raw_obs
+ self._last_obs = self._wrap_raw_obs(raw_obs)
+ info_out = self._note_info(info)
+ except Exception as exc:
+ _emit_reset_trace_marker(
+ "official_behavior_backend.reset.exit",
+ status="error",
+ error_type=type(exc).__name__,
+ error=str(exc),
+ elapsed_s=time.monotonic() - started_at,
+ )
+ raise
+ _emit_reset_trace_marker(
+ "official_behavior_backend.reset.exit",
+ status="ok",
+ total_env_steps=int(self._total_env_steps),
+ observation_keys=sorted(self._last_obs),
+ elapsed_s=time.monotonic() - started_at,
+ )
+ return self._last_obs, info_out
+
+ def current_observation(self) -> tuple[dict[str, Any], dict[str, Any]]:
+ if self._last_obs is None:
+ raise RuntimeError("no BEHAVIOR observation is available before reset")
+ return self._last_obs, self._last_info
+
+ def _remember_gripper_commands(self, action: np.ndarray) -> None:
+ for hand in ("left", "right"):
+ segment = ENV_ACTION_SEGMENTS[f"{hand}_gripper"]
+ value = float(np.asarray(action[segment], dtype=np.float32).reshape(-1)[0])
+ if np.isfinite(value):
+ self._gripper_latch[hand] = value
+
+ def _latest_raw_proprio(self) -> np.ndarray:
+ obs, _info = self.current_observation()
+ raw = np.asarray(obs.get("states"), dtype=np.float32)
+ required = max(segment.stop or 0 for segment in RAW_PROPRIO_SEGMENTS.values())
+ if raw.ndim != 1 or raw.shape[0] < required:
+ raise ValueError(
+ "raw R1Pro proprio must be a vector with at least "
+ f"{required} values, got {raw.shape}"
+ )
+ if not np.isfinite(raw).all():
+ raise ValueError("raw R1Pro proprio contains NaN or infinity")
+ return raw
+
+ def _hold_action_from_current_proprio(self) -> np.ndarray:
+ raw = self._latest_raw_proprio()
+ action = np.zeros(ACTION_DIM, dtype=np.float32)
+ action[ENV_ACTION_SEGMENTS["base"]] = 0.0
+ for segment_name in ("trunk", "left_arm", "right_arm"):
+ action[ENV_ACTION_SEGMENTS[segment_name]] = raw[
+ RAW_PROPRIO_SEGMENTS[segment_name]
+ ]
+ action[ENV_ACTION_SEGMENTS["left_gripper"]] = self._gripper_latch["left"]
+ action[ENV_ACTION_SEGMENTS["right_gripper"]] = self._gripper_latch["right"]
+ return _validate_action_chunk(action[None, :])[0]
+
+ def _motion_error(
+ self,
+ name: str,
+ kwargs: Mapping[str, Any],
+ *,
+ stop_reason: str,
+ error: str,
+ ) -> dict[str, Any]:
+ return {
+ "status": "failed",
+ "name": name,
+ "primitive_success": False,
+ "task_success": self.official_success_latched,
+ "stop_reason": stop_reason,
+ "error": error,
+ "request": _strict_public_json(dict(kwargs)),
+ "info": self._last_info,
+ }
+
+ def _gripper_command(
+ self,
+ name: str,
+ kwargs: Mapping[str, Any],
+ *,
+ command: float,
+ ) -> dict[str, Any]:
+ request = dict(kwargs)
+ hand = request.get("hand")
+ if hand not in {"left", "right"}:
+ raise ValueError("hand must be 'left' or 'right'")
+ if "visual_hand_check" not in request:
+ raise ValueError("visual_hand_check is required")
+ if self.official_success_latched:
+ return {
+ "status": "skipped",
+ "name": name,
+ "primitive_success": False,
+ "task_success": True,
+ "stop_reason": "already_officially_successful",
+ "request": _strict_public_json(request),
+ "info": self._last_info,
+ }
+ if self._episode_ended:
+ return self._motion_error(
+ name,
+ request,
+ stop_reason="episode_ended",
+ error="BEHAVIOR episode already terminated or truncated",
+ )
+
+ try:
+ action = self._hold_action_from_current_proprio()
+ action[ENV_ACTION_SEGMENTS[f"{hand}_gripper"]] = float(command)
+ chunk = np.repeat(action[None, :], GRIPPER_COMMAND_CONTROL_CYCLES, axis=0)
+ _obs, reward, terminated, truncated, info = self.chunk_step(
+ chunk,
+ return_all_frames=False,
+ )
+ except Exception as exc:
+ return self._motion_error(
+ name,
+ request,
+ stop_reason="error",
+ error=str(exc),
+ )
+ executed_steps = int(info.get("executed_steps") or 0)
+ stop_reason = str(info.get("stop_reason") or "requested_actions_completed")
+ result: dict[str, Any] = {
+ "status": "ok" if executed_steps > 0 else "failed",
+ "name": name,
+ "primitive_success": executed_steps > 0,
+ "task_success": self.official_success_latched,
+ "stop_reason": stop_reason,
+ "hand": hand,
+ "gripper_command": float(command),
+ "requested_steps": GRIPPER_COMMAND_CONTROL_CYCLES,
+ "executed_steps": executed_steps,
+ "total_env_steps": int(self.total_env_steps),
+ "reward": float(reward),
+ "terminated": bool(terminated),
+ "truncated": bool(truncated),
+ "action_shape": [1, ACTION_DIM],
+ "action_chunk_shape": [int(chunk.shape[0]), int(chunk.shape[1])],
+ "hold_action_source": "raw_proprio_reordered_with_gripper_command_latches",
+ "visual_hand_check": _strict_public_json(request["visual_hand_check"]),
+ "visual_hand_check_verification": "not_verified",
+ "request": _strict_public_json(request),
+ "info": info,
+ "_observation": self._last_obs,
+ }
+ if "release_visual_check" in request:
+ result["release_visual_check"] = _strict_public_json(
+ request["release_visual_check"]
+ )
+ result["release_visual_check_verification"] = "not_verified"
+ if self.official_success_latched:
+ result["official_success_receipt"] = self.official_success_receipt
+ return result
+
+ def step(
+ self,
+ action: Any,
+ ) -> tuple[dict[str, Any] | None, float, bool, bool, dict[str, Any]]:
+ array = np.asarray(action, dtype=np.float32)
+ if array.shape != (ACTION_DIM,) or not np.isfinite(array).all():
+ raise ValueError(f"BEHAVIOR action must be finite [{ACTION_DIM}]")
+ return self.chunk_step(array[None, :], return_all_frames=False)
+
+ def chunk_step(
+ self,
+ actions: Any,
+ *,
+ return_all_frames: bool = False,
+ ) -> tuple[Any, float, bool, bool, dict[str, Any]]:
+ action_array = _validate_action_chunk(actions)
+ last_obs: Any = None
+ last_reward = 0.0
+ terminated = False
+ truncated = False
+ last_info: dict[str, Any] = {}
+ success_step: int | None = None
+ executed_steps = 0
+ stop_reason = "requested_actions_completed"
+ frames: list[dict[str, Any]] = []
+
+ for step_offset, action in enumerate(action_array):
+ raw_obs, reward, step_terminated, step_truncated, info = self._step_one_raw(
+ action
+ )
+ self._remember_gripper_commands(action)
+ executed_steps = step_offset + 1
+ self._total_env_steps += 1
+ last_obs = raw_obs
+ last_reward = float(reward)
+ last_info = info
+ terminated = bool(step_terminated)
+ truncated = bool(step_truncated)
+ if return_all_frames and raw_obs is not None:
+ frames.append(self._wrap_raw_obs(raw_obs))
+ if _raw_success(info):
+ success_step = step_offset
+ stop_reason = "official_task_success"
+ break
+ if terminated:
+ stop_reason = "terminated"
+ break
+ if truncated:
+ stop_reason = "truncated"
+ break
+
+ if last_obs is not None:
+ self._last_raw_obs = last_obs
+ self._last_obs = self._wrap_raw_obs(last_obs)
+ if terminated or truncated or success_step is not None:
+ self._episode_ended = True
+ telemetry = {
+ "executed_steps": int(executed_steps),
+ "stop_reason": stop_reason,
+ "success_step_in_chunk": success_step,
+ }
+ info_out = self._note_info(last_info, telemetry=telemetry)
+ observation: Any = frames if return_all_frames else self._last_obs
+ return observation, last_reward, terminated, truncated, info_out
+
+ def get_task_language(self) -> str:
+ return str(self.identity["task_language"])
+
+ def healthz(self) -> dict[str, Any]:
+ return {
+ "status": "ok",
+ "runtime": "behavior_rlinf_env",
+ "pid": os.getpid(),
+ "total_env_steps": self.total_env_steps,
+ "official_success_latched": self.official_success_latched,
+ }
+
+ def get_env_meta(self) -> dict[str, Any]:
+ return dict(self.meta)
+
+ def render_camera(self, camera_name: str = "head", **_kwargs: Any) -> np.ndarray:
+ obs, _info = self.current_observation()
+ camera = _physical_camera(camera_name)
+ if camera == "head":
+ return np.asarray(obs["main_images"], dtype=np.uint8)
+ index = 0 if camera == "left_wrist" else 1
+ return np.asarray(obs["wrist_images"][index], dtype=np.uint8)
+
+ def get_camera_meta(
+ self,
+ camera_name: str = "head",
+ **_kwargs: Any,
+ ) -> dict[str, Any]:
+ camera = _physical_camera(camera_name)
+ frame = self._get_camera_frames()[camera]
+ return {
+ "camera_name": camera,
+ "available": True,
+ "rgb_shape": list(frame["rgb"].shape),
+ "rgb_dtype": str(frame["rgb"].dtype),
+ "calibration_available": True,
+ "depth_available": True,
+ "intrinsic": frame["intrinsic"],
+ "camera_to_world": frame["camera_to_world"],
+ }
+
+ def observe(self, camera: str = "head", **kwargs: Any) -> dict[str, Any]:
+ from robots.behavior.schemas import (
+ FRAME_REVIEW_ASSESSMENTS,
+ validate_observe_request,
+ )
+
+ request = validate_observe_request(camera=camera, **kwargs)
+ camera = _physical_camera(camera)
+ if request.get("head_view", "center") != "center":
+ return self._motion_error(
+ "observe",
+ request,
+ stop_reason="head_view_unavailable",
+ error="R1Pro has no movable head camera; use the current physical view",
+ )
+ review = request.get("frame_review")
+ probe = request.get("depth_probe")
+ if review is not None or probe is not None:
+ value = review if review is not None else probe
+ frame_id = f"behavior-{self.total_env_steps}-{camera}"
+ if (
+ not isinstance(value, Mapping)
+ or value.get("frame_id") != frame_id
+ or self._camera_frame_step != self.total_env_steps
+ ):
+ return self._motion_error(
+ "observe",
+ request,
+ stop_reason="stale_frame",
+ error="review/probe requires the current observed frame",
+ )
+ if review is not None:
+ if review.get("assessment") not in FRAME_REVIEW_ASSESSMENTS:
+ raise ValueError("invalid frame_review assessment")
+ return {
+ "status": "ok",
+ "primitive_success": True,
+ "frame_review": dict(review),
+ "verification": "planner_assessment_not_independently_verified",
+ "info": self._last_info,
+ }
+ if probe.get("assessment") != "target_point_visually_confirmed":
+ raise ValueError("invalid depth_probe assessment")
+ result = self.pixel_to_world(
+ camera=camera, **{k: v for k, v in probe.items() if k != "assessment"}
+ )
+ result.pop("projection_id", None)
+ return {
+ **result,
+ "depth_probe": dict(probe),
+ "verification": "depth_measured_visual_assessment_not_verified",
+ "info": self._last_info,
+ }
+ frames = self._get_camera_frames()
+ payloads = {name: _png_bytes(frame["rgb"]) for name, frame in frames.items()}
+ depths = {}
+ for name, frame in frames.items():
+ gray = np.nan_to_num(frame["depth"], nan=0, posinf=0, neginf=0)
+ gray = np.rint(np.clip(gray / 5.0, 0, 1) * 255).astype(np.uint8)
+ depths[name] = _png_bytes(np.repeat(gray[..., None], 3, axis=-1))
+ frame_id = f"behavior-{self.total_env_steps}-{camera}"
+ return {
+ "status": "ok",
+ "camera": camera,
+ "paired_hand": request.get("paired_hand"),
+ "frame_id": frame_id,
+ "step": self.total_env_steps,
+ "_image_bytes": payloads["head"],
+ "_depth_image_bytes": depths["head"],
+ "_image_left_wrist_bytes": payloads["left_wrist"],
+ "_depth_left_wrist_bytes": depths["left_wrist"],
+ "_image_right_wrist_bytes": payloads["right_wrist"],
+ "_depth_right_wrist_bytes": depths["right_wrist"],
+ "depth_display_range_m": [0, 5],
+ "frames": _write_frame_files(
+ payloads,
+ output_dir=self.output_dir,
+ group_id=frame_id,
+ ),
+ "info": self._last_info,
+ }
+
+ def move_to(self, **kwargs: Any) -> dict[str, Any]:
+ if kwargs.get("hand") == "both":
+ return self._move_both_hands_to(kwargs)
+ return self._move_single_hand_to(kwargs)
+
+ def _move_single_hand_to(self, kwargs: Mapping[str, Any]) -> dict[str, Any]:
+ return self._plan_motion("move_to", kwargs, {kwargs["hand"]: kwargs["target"]})
+
+ def _move_both_hands_to(self, kwargs: Mapping[str, Any]) -> dict[str, Any]:
+ from robots.behavior.schemas import (
+ validate_move_both_targets,
+ validate_move_both_visual_hand_checks,
+ )
+
+ validate_move_both_visual_hand_checks(kwargs.get("visual_hand_checks"))
+ return self._plan_motion(
+ "move_to", kwargs, validate_move_both_targets(kwargs.get("targets"))
+ )
+
+ def _plan_motion(
+ self, name: str, request: Mapping[str, Any], targets: dict
+ ) -> dict:
+ from scipy.spatial.transform import Rotation
+
+ from robots.behavior.motion import BehaviorMotionPlanner, get_planning_state
+
+ started = self.total_env_steps
+ try:
+ if self._episode_ended:
+ return self._motion_error(
+ name,
+ request,
+ stop_reason="episode_ended",
+ error="episode has ended",
+ )
+ state = self._call_actor(get_planning_state)
+ poses = {}
+ for hand, target in targets.items():
+ live = state["hands"][hand]
+ position = np.array(live["position"], dtype=np.float64, copy=True)
+ rotation = Rotation.from_quat(live["quaternion_xyzw"])
+ orientation = live["quaternion_xyzw"]
+ if name == "rotate_wrist":
+ angle = float(request["angle_deg"])
+ if not np.isfinite(angle) or abs(angle) > 180:
+ raise ValueError(
+ "angle_deg must be finite and within [-180,180]"
+ )
+ direction = request.get("direction", "counterclockwise")
+ if direction not in {"clockwise", "counterclockwise"}:
+ raise ValueError("invalid wrist rotation direction")
+ angle *= -1 if direction == "clockwise" else 1
+ orientation = (
+ rotation * Rotation.from_rotvec([0, 0, np.deg2rad(angle)])
+ ).as_quat()
+ elif "projection_id" in target:
+ point = self._projection(target["projection_id"])
+ goal = np.asarray(point["world_xyz"])
+ delta = goal - position
+ standoff = float(target.get("standoff_m", 0))
+ if not np.isfinite(standoff) or standoff < 0:
+ raise ValueError("standoff_m must be finite and nonnegative")
+ position = (
+ goal - delta / max(np.linalg.norm(delta), 1e-12) * standoff
+ )
+ else:
+ delta = np.asarray(target["delta_xyz"], dtype=np.float64)
+ if delta.shape != (3,) or not np.isfinite(delta).all():
+ raise ValueError("delta_xyz must be finite [3]")
+ if target["frame"] == "eef":
+ delta = rotation.apply(delta)
+ elif target["frame"] != "world":
+ raise ValueError("frame must be world or eef")
+ position += delta
+ poses[hand] = {"position": position, "quaternion_xyzw": orientation}
+ if self._motion_planner is None:
+ self._motion_planner = BehaviorMotionPlanner()
+ planned = self._motion_planner.plan(state, poses)
+ if not planned["success"]:
+ return self._motion_error(
+ name,
+ request,
+ stop_reason=planned["stop_reason"],
+ error=planned["details"],
+ )
+ positions = planned["positions"]
+ times = np.arange(len(positions)) * planned["dt"]
+ dt = state["control_dt"]
+ if times[-1] > 30 or not np.isfinite(positions).all():
+ raise ValueError(
+ "planned trajectory exceeds 30 seconds or is nonfinite"
+ )
+ sample_times = np.minimum(
+ np.arange(int(np.ceil(times[-1] / dt)) + 1) * dt, times[-1]
+ )
+ positions = np.stack(
+ [
+ np.interp(sample_times, times, positions[:, i])
+ for i in range(positions.shape[1])
+ ],
+ axis=1,
+ )
+ hold = self._hold_action_from_current_proprio()
+ actions = np.repeat(hold[None, :], len(positions), axis=0)
+ for i, joint_name in enumerate(planned["joint_names"]):
+ hand, _, number = joint_name.partition("_arm_joint")
+ actions[
+ :, ENV_ACTION_SEGMENTS[f"{hand}_arm"].start + int(number) - 1
+ ] = positions[:, i]
+ reason = "trajectory_completed"
+ for offset in range(0, len(actions), 32):
+ _, _, terminated, truncated, info = self.chunk_step(
+ actions[offset : offset + 32]
+ )
+ if self.official_success_latched or terminated or truncated:
+ reason = str(info["stop_reason"])
+ break
+ # Terminal observations are already captured by chunk_step. Never
+ # issue another actor query after official success or truncation.
+ final = self._get_motion_state() if not self._episode_ended else None
+ errors = {
+ hand: float(
+ np.linalg.norm(
+ np.asarray(final["hands"][hand]["position"])
+ - target["position"]
+ )
+ )
+ for hand, target in poses.items()
+ if final is not None
+ }
+ angles = {
+ hand: float(
+ (
+ Rotation.from_quat(
+ final["hands"][hand]["quaternion_xyzw"]
+ ).inv()
+ * Rotation.from_quat(target["quaternion_xyzw"])
+ ).magnitude()
+ )
+ for hand, target in poses.items()
+ if final is not None
+ }
+ reached = (
+ final is not None
+ and all(x <= 0.01 for x in errors.values())
+ and all(x <= 0.1 for x in angles.values())
+ )
+ succeeded = self.official_success_latched or (
+ reason == "trajectory_completed" and reached
+ )
+ if reason == "trajectory_completed" and not reached:
+ reason = "tracking_error"
+ return {
+ "status": "ok" if succeeded else "failed",
+ "name": name,
+ "primitive_success": succeeded,
+ "task_success": self.official_success_latched,
+ "stop_reason": reason,
+ "executed_steps": self.total_env_steps - started,
+ "total_env_steps": self.total_env_steps,
+ "position_error_m": errors,
+ "orientation_error_rad": angles,
+ "_observation": self._last_obs,
+ "visual_hand_check_verification": "not_verified",
+ "request": _strict_public_json(request),
+ "info": self._last_info,
+ }
+ except Exception as exc:
+ result = self._motion_error(
+ name, request, stop_reason="error", error=str(exc)
+ )
+ result["executed_steps"] = self.total_env_steps - started
+ return result
+
+ def _projection(self, projection_id: str) -> dict:
+ if (
+ self._camera_frame_step != self.total_env_steps
+ or projection_id not in self._projections
+ ):
+ raise ValueError("projection is not from the current observed frame")
+ return self._projections[projection_id]
+
+ def navigate_to(self, **kwargs: Any) -> dict[str, Any]:
+ from scipy.spatial.transform import Rotation
+
+ from robots.behavior.motion import get_planning_state, navigation_collision
+ from robots.behavior.schemas import validate_relative_navigation_motion
+
+ started = self.total_env_steps
+ try:
+ if self._episode_ended:
+ return self._motion_error(
+ "navigate_to",
+ kwargs,
+ stop_reason="episode_ended",
+ error="episode has ended",
+ )
+ state = self._call_actor(get_planning_state)
+ position = np.asarray(state["base_position"])
+ yaw = Rotation.from_quat(state["base_quaternion_xyzw"]).as_euler("xyz")[2]
+ target, target_yaw = position.copy(), yaw
+ if "relative_motion" in kwargs:
+ motion = validate_relative_navigation_motion(kwargs["relative_motion"])
+ if motion["kind"] == "translation":
+ distance = motion["distance_m"] * (
+ 1 if motion["direction"] == "forward" else -1
+ )
+ target[:2] += distance * np.array([np.cos(yaw), np.sin(yaw)])
+ else:
+ target_yaw += np.deg2rad(motion["angle_deg"]) * (
+ 1 if motion["direction"] == "left" else -1
+ )
+ else:
+ check = kwargs.get("navigation_visual_check")
+ if (
+ not isinstance(check, Mapping)
+ or check.get("camera") != "head"
+ or check.get("frame_id") != f"behavior-{self.total_env_steps}-head"
+ or check.get("assessment") != "navigation_target_visually_confirmed"
+ ):
+ raise ValueError(
+ "navigation_visual_check must confirm the head target"
+ )
+ goal = np.asarray(
+ self._projection(kwargs["projection_id"])["world_xyz"]
+ )
+ standoff = float(kwargs.get("standoff_m", 0.85))
+ if not 0.45 <= standoff <= 1.5:
+ raise ValueError("standoff_m must be within [0.45,1.5]")
+ delta = goal[:2] - position[:2]
+ length = np.linalg.norm(delta)
+ target[:2] += delta / max(length, 1e-12) * max(0, length - standoff)
+ target_yaw = np.arctan2(delta[1], delta[0])
+ obstacle = navigation_collision(state, target)
+ if obstacle:
+ return self._motion_error(
+ "navigate_to",
+ kwargs,
+ stop_reason="collision",
+ error=f"swept footprint intersects {obstacle}",
+ )
+ dt = state["control_dt"]
+ reason = "duration_limit"
+ for _ in range(int(np.ceil(30 / dt))):
+ live = state["base_position"]
+ yaw = Rotation.from_quat(state["base_quaternion_xyzw"]).as_euler("xyz")[
+ 2
+ ]
+ delta = target[:2] - np.asarray(live)[:2]
+ angle = (target_yaw - yaw + np.pi) % (2 * np.pi) - np.pi
+ if np.linalg.norm(delta) < 0.01 and abs(angle) < np.deg2rad(1):
+ reason = "target_reached"
+ break
+ world_velocity = delta * min(
+ 2.0, 0.2 / max(np.linalg.norm(delta), 1e-12)
+ )
+ local_velocity = (
+ np.array([[np.cos(yaw), np.sin(yaw)], [-np.sin(yaw), np.cos(yaw)]])
+ @ world_velocity
+ )
+ action = self._hold_action_from_current_proprio()
+ # RLinf base controller scales normalized x/y commands by 0.75 m/s.
+ action[:2] = local_velocity / 0.75
+ action[2] = np.clip(2 * angle, -0.4, 0.4)
+ _, _, terminated, truncated, info = self.chunk_step(action[None, :])
+ if self.official_success_latched or terminated or truncated:
+ reason = str(info["stop_reason"])
+ break
+ state = self._call_actor(get_planning_state)
+ obstacle = navigation_collision(state, target)
+ if obstacle:
+ reason = "collision"
+ break
+ if not self._episode_ended:
+ _, _, terminated, truncated, info = self.chunk_step(
+ self._hold_action_from_current_proprio()[None, :]
+ )
+ if self.official_success_latched or terminated or truncated:
+ reason = str(info["stop_reason"])
+ succeeded = self.official_success_latched or reason == "target_reached"
+ return {
+ "status": "ok" if succeeded else "failed",
+ "name": "navigate_to",
+ "primitive_success": succeeded,
+ "_observation": self._last_obs,
+ "task_success": self.official_success_latched,
+ "stop_reason": reason,
+ "executed_steps": self.total_env_steps - started,
+ "total_env_steps": self.total_env_steps,
+ "request": _strict_public_json(kwargs),
+ "info": self._last_info,
+ }
+ except Exception as exc:
+ # If a read/planning error followed a base command, release that
+ # command through the same monitored action channel before returning.
+ brake_error = None
+ if self.total_env_steps > started and not self._episode_ended:
+ try:
+ self.chunk_step(self._hold_action_from_current_proprio()[None, :])
+ except Exception as brake_exc:
+ brake_error = str(brake_exc)
+ result = self._motion_error(
+ "navigate_to", kwargs, stop_reason="error", error=str(exc)
+ )
+ result["executed_steps"] = self.total_env_steps - started
+ if brake_error is not None:
+ result["brake_error"] = brake_error
+ return result
+
+ def rotate_wrist(self, **kwargs: Any) -> dict[str, Any]:
+ check = kwargs.get("visual_hand_check")
+ if (
+ not isinstance(check, Mapping)
+ or set(check) != {"camera", "frame_id", "selected_hand", "assessment"}
+ or check.get("camera") not in PHYSICAL_CAMERAS
+ or not isinstance(check.get("frame_id"), str)
+ or not check.get("frame_id")
+ or check.get("assessment") != "selected_hand_visually_confirmed"
+ or check.get("selected_hand") != kwargs.get("hand")
+ ):
+ raise ValueError("visual_hand_check must identify the selected hand")
+ return self._plan_motion("rotate_wrist", kwargs, {kwargs["hand"]: {}})
+
+ def open(self, **kwargs: Any) -> dict[str, Any]:
+ return self._gripper_command("open", kwargs, command=GRIPPER_OPEN_COMMAND)
+
+ def close(self, **kwargs: Any) -> dict[str, Any]:
+ if kwargs:
+ return self._gripper_command("close", kwargs, command=GRIPPER_CLOSE_COMMAND)
+ if self._closed:
+ return {"status": "ok", "closed": True, "already_closed": True}
+ if self._motion_planner is not None:
+ self._motion_planner.close()
+ closer = getattr(self._env, "close", None)
+ if callable(closer):
+ closer()
+ self._closed = True
+ return {"status": "ok", "closed": True}
+
+ def press(self, **kwargs: Any) -> dict[str, Any]:
+ """Press along the currently aligned hand axis, at most 2 cm.
+
+ Differential IK keeps orientation fixed. This local contact motion does
+ not choose a button or claim official success from contact alone.
+ """
+ request = dict(kwargs)
+ hand = request.get("hand")
+ if hand not in {"left", "right"}:
+ raise ValueError("hand must be 'left' or 'right'")
+ check = request.get("visual_hand_check")
+ if not isinstance(check, Mapping) or check.get("selected_hand") != hand:
+ raise ValueError("visual_hand_check must identify the selected hand")
+ duration = float(request.get("duration_s", 1.0))
+ if not np.isfinite(duration) or not 0.0 < duration <= 10.0:
+ raise ValueError("duration_s must be finite and in (0, 10]")
+ if self._episode_ended or self.official_success_latched:
+ return self._motion_error(
+ "press", request, stop_reason="episode_ended", error="episode has ended"
+ )
+
+ started = self.total_env_steps
+ try:
+ state = self._get_motion_state()
+ initial = state["hands"][hand]
+ origin = np.asarray(initial["position"], dtype=np.float64)
+ direction = np.asarray(initial["approach_direction"], dtype=np.float64)
+ dt = float(state["control_dt"])
+ if not np.isfinite(dt) or dt <= 0:
+ raise ValueError("invalid environment control timestep")
+ target = origin + direction * 0.02
+ stop_reason = "duration_limit"
+ contacts = []
+ travel = 0.0
+ for _ in range(int(np.ceil(duration / dt))):
+ live = state["hands"][hand]
+ position = np.asarray(live["position"], dtype=np.float64)
+ travel = float(np.linalg.norm(position - origin))
+ contacts = live["contacts"]
+ if contacts:
+ stop_reason = "contact"
+ break
+ if travel >= 0.02:
+ stop_reason = "travel_limit"
+ break
+ error = target - position
+ if np.linalg.norm(error) <= 0.001:
+ stop_reason = "target_reached"
+ break
+ delta = error * min(1.0, 0.02 * dt / np.linalg.norm(error))
+ jacobian = np.asarray(live["jacobian"], dtype=np.float64)
+ if jacobian.shape != (6, 7) or not np.isfinite(jacobian).all():
+ raise ValueError("expected a finite [6,7] arm Jacobian")
+ twist = np.concatenate((delta, np.zeros(3)))
+ dq = jacobian.T @ np.linalg.solve(
+ jacobian @ jacobian.T + 1e-4 * np.eye(6), twist
+ )
+ # Cap joint speed at 0.5 rad/s; preserve unselected joints/latches.
+ dq *= min(1.0, 0.5 * dt / max(float(np.max(np.abs(dq))), 1e-12))
+ q = np.asarray(live["joint_positions"]) + dq
+ if np.any(q < live["joint_lower_limits"]) or np.any(
+ q > live["joint_upper_limits"]
+ ):
+ stop_reason = "joint_limit"
+ break
+ action = self._hold_action_from_current_proprio()
+ action[ENV_ACTION_SEGMENTS[f"{hand}_arm"]] = q
+ _, _, terminated, truncated, info = self.chunk_step(action[None, :])
+ if self.official_success_latched or terminated or truncated:
+ stop_reason = str(info["stop_reason"])
+ break
+ state = self._get_motion_state()
+ live = state["hands"][hand]
+ travel = float(np.linalg.norm(np.asarray(live["position"]) - origin))
+ succeeded = stop_reason in {
+ "contact",
+ "target_reached",
+ "official_task_success",
+ }
+ return {
+ "status": "ok" if succeeded else "failed",
+ "name": "press",
+ "hand": hand,
+ "_observation": self._last_obs,
+ "primitive_success": succeeded,
+ "task_success": self.official_success_latched,
+ "stop_reason": stop_reason,
+ "executed_steps": self.total_env_steps - started,
+ "total_env_steps": self.total_env_steps,
+ "travel_m": travel,
+ "travel_limit_m": 0.02,
+ "contacts": contacts,
+ "contact_verification": "any_non_robot_contact_not_button_verified",
+ "visual_hand_check": _strict_public_json(check),
+ "visual_hand_check_verification": "not_verified",
+ "request": _strict_public_json(request),
+ "info": self._last_info,
+ }
+ except Exception as exc:
+ result = self._motion_error(
+ "press", request, stop_reason="error", error=str(exc)
+ )
+ result["executed_steps"] = self.total_env_steps - started
+ return result
+
+ def _get_motion_state(self) -> dict[str, Any]:
+ from robots.behavior.motion import get_motion_state
+
+ # RLinf owns the OG actor; query it on its existing serial execution lane.
+ return self._call_actor(get_motion_state)
+
+ def pixel_to_world(self, **kwargs: Any) -> dict[str, Any]:
+ camera = _physical_camera(kwargs.get("camera"))
+ expected = f"behavior-{self.total_env_steps}-{camera}"
+ if (
+ kwargs.get("frame_id") != expected
+ or self._camera_frame_step != self.total_env_steps
+ ):
+ return self._motion_error(
+ "pixel_to_world",
+ kwargs,
+ stop_reason="stale_frame",
+ error="observe the current frame before projection",
+ )
+ frame = self._camera_frames[camera]
+ u, v = kwargs["u"], kwargs["v"]
+ depth = frame["depth"]
+ if (
+ type(u) is not int
+ or type(v) is not int
+ or not 0 <= u < depth.shape[1]
+ or not 0 <= v < depth.shape[0]
+ ):
+ raise ValueError("pixel must be an integer inside the observed image")
+ window = kwargs.get("depth_window_px", 7)
+ if type(window) is not int or not 1 <= window <= 31:
+ raise ValueError("depth_window_px must be in [1,31]")
+ radius = window // 2
+ samples = depth[
+ max(0, v - radius) : v + radius + 1, max(0, u - radius) : u + radius + 1
+ ]
+ valid = samples[np.isfinite(samples) & (samples > 0)]
+ if not valid.size:
+ return self._motion_error(
+ "pixel_to_world",
+ kwargs,
+ stop_reason="invalid_depth",
+ error="no finite positive depth at this pixel",
+ )
+ z = float(np.median(valid))
+ k = frame["intrinsic"]
+ # USD cameras face -Z, +Y up; image rows increase downwards.
+ optical = np.array(
+ [(u - k[0, 2]) * z / k[0, 0], -(v - k[1, 2]) * z / k[1, 1], -z, 1.0]
+ )
+ xyz = (frame["camera_to_world"] @ optical)[:3]
+ value = {
+ "camera": camera,
+ "frame_id": expected,
+ "u": u,
+ "v": v,
+ "world_xyz": xyz.tolist(),
+ "depth_m": z,
+ }
+ projection_id = _canonical_json_sha256(value)
+ self._projections[projection_id] = value
+ return {
+ "status": "ok",
+ "primitive_success": True,
+ "task_success": self.official_success_latched,
+ "projection_id": projection_id,
+ **value,
+ }
+
+ def _get_camera_frames(self) -> dict[str, Any]:
+ from robots.behavior.motion import get_camera_observation
+
+ if self._camera_frame_step != self.total_env_steps:
+ self._camera_frames = self._call_actor(get_camera_observation)
+ self._camera_frame_step = self.total_env_steps
+ self._projections = {}
+ return self._camera_frames
+
+ def _call_actor(self, function):
+ import ray
+
+ pool = self._env.pool
+ index = self._env.pool_offset
+ return ray.get(
+ pool.env_processes[index % pool.num_env_subprocess].__ray_call__.remote(
+ function, index // pool.num_env_subprocess
+ )
+ )
+
+
+def _physical_camera(value: Any) -> str:
+ camera = str(value or "head")
+ aliases = {
+ "main": "head",
+ "zed": "head",
+ "left": "left_wrist",
+ "right": "right_wrist",
+ }
+ camera = aliases.get(camera, camera)
+ if camera not in PHYSICAL_CAMERAS:
+ raise ValueError("camera must be head, left_wrist, or right_wrist")
+ return camera
+
+
+__all__ = [
+ "ACTION_DIM",
+ "ACTION_HORIZON",
+ "PHYSICAL_CAMERAS",
+ "OfficialBehaviorBackend",
+ "build_behavior_env_config",
+ "discover_rlinf_root",
+ "ensure_rlinf_import_path",
+]
diff --git a/robots/behavior/robot_spec.py b/robots/behavior/robot_spec.py
new file mode 100644
index 000000000..f5ee36ad0
--- /dev/null
+++ b/robots/behavior/robot_spec.py
@@ -0,0 +1,128 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR robot extension: RobotSpec factory and toolkit bridge."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+from robots.behavior.prompt_bundle import system_prompt, user_prompt
+from rpent.dashboard.events import DashboardEventSink
+from rpent.memory import MemoryManager
+from rpent.robots.prompt_bundle import PromptBundle
+from rpent.robots.robot_spec import RobotSpec, RunConfig
+
+BEHAVIOR_DASHBOARD_SPEC = {
+ "task": {
+ "command": "/rpent-task",
+ "usage": "/rpent-task ",
+ "fields": (
+ {
+ "name": "task_name",
+ "suggestions": ("turning_on_radio", "picking_up_trash"),
+ },
+ {"name": "public_seed", "kind": "integer", "minimum": 0},
+ ),
+ "display": "{task_name} / s{public_seed}",
+ "output_slug": "{task_name}_s{public_seed}",
+ },
+ "runtime_components": (
+ {"name": "env", "label": "ENV", "scope": "unique"},
+ {"name": "vla", "label": "VLA", "scope": "shared"},
+ {"name": "dino", "label": "DINO", "scope": "shared"},
+ ),
+ "frame_channels": (
+ {
+ "name": "head",
+ "label": "head camera",
+ "result_key": "_image_bytes",
+ "legacy_path_key": "image_cam_path",
+ },
+ {
+ "name": "left_wrist",
+ "label": "left wrist",
+ "result_key": "_image_left_wrist_bytes",
+ },
+ {
+ "name": "right_wrist",
+ "label": "right wrist",
+ "result_key": "_image_right_wrist_bytes",
+ },
+ ),
+}
+
+
+def get_robot_spec() -> RobotSpec:
+ from robots.behavior import runtime
+
+ return RobotSpec(
+ name="behavior",
+ prompts=PromptBundle(system=system_prompt, user=user_prompt),
+ add_cli_args=runtime.add_cli_args,
+ parse_config=runtime.parse_config,
+ init_runtime=runtime.init_runtime,
+ on_explore_session=runtime.on_explore_session,
+ dashboard=BEHAVIOR_DASHBOARD_SPEC,
+ )
+
+
+def get_toolkit(
+ *,
+ primitives_kwargs: dict[str, Any],
+ dashboard_events: DashboardEventSink,
+ config: RunConfig,
+ mode: str | None = None,
+ attempts_per_session: int = 0,
+ state_output_dir: Path | str | None = None,
+):
+ """Return the BEHAVIOR toolkit through the standard main contract."""
+
+ from robots.behavior.toolkit import BehaviorToolkit
+
+ toolkit_kwargs = dict(primitives_kwargs)
+ if attempts_per_session > 0:
+ raise ValueError(
+ "BEHAVIOR explore runs one attempt per session; use --explore-sessions"
+ )
+ if mode is None:
+ behavior_mode = str(config.prompt_vars.get("behavior_mode", "eval"))
+ elif mode == "exploration":
+ behavior_mode = "explore"
+ elif mode == "evaluation":
+ behavior_mode = "eval"
+ else:
+ raise ValueError(f"unsupported BEHAVIOR toolkit mode: {mode!r}")
+ if behavior_mode not in {"eval", "explore"}:
+ raise ValueError(f"unsupported BEHAVIOR toolkit mode: {behavior_mode!r}")
+ toolkit_kwargs["behavior_phase"] = behavior_mode
+ memory_dir = config.prompt_vars.get("memory_dir")
+ if not memory_dir:
+ raise ValueError("BEHAVIOR RunConfig is missing memory_dir")
+ memory = MemoryManager(
+ root=Path(memory_dir),
+ memory_access="inbox_write" if behavior_mode == "explore" else "read_only",
+ inbox_cell_tag=config.recipe_tag if behavior_mode == "explore" else None,
+ )
+ return BehaviorToolkit(
+ primitives_kwargs=toolkit_kwargs,
+ dashboard_events=dashboard_events,
+ memory=memory,
+ config=config,
+ state_output_dir=state_output_dir,
+ )
+
+
+__all__ = ["BEHAVIOR_DASHBOARD_SPEC", "get_robot_spec", "get_toolkit"]
diff --git a/robots/behavior/runtime.py b/robots/behavior/runtime.py
new file mode 100644
index 000000000..836544480
--- /dev/null
+++ b/robots/behavior/runtime.py
@@ -0,0 +1,860 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Standard RPent runtime hooks for BEHAVIOR."""
+
+from __future__ import annotations
+
+import argparse
+import os
+import re
+import time
+from datetime import datetime
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+from robots.behavior.policy_checkpoint import (
+ POLICY_CHECKPOINT_ENV,
+ SHARED_POLICY_CHECKPOINT_PATH,
+ SHARED_POLICY_PROFILE_ID,
+ write_policy_checkpoint_manifest,
+)
+from robots.behavior.schemas import (
+ ACTION_DIM,
+ DEFAULT_ACTION_CHUNK,
+ behavior_tool_specs_for_task,
+)
+from robots.behavior.task_specs import (
+ BehaviorTaskSpec,
+ get_task_spec,
+ get_task_spec_by_index,
+)
+from rpent.dashboard.events import DashboardEventSink
+from rpent.robots.robot_spec import RunConfig
+from rpent.robots.runtime import stop_owned_daemons, try_spawn_server, try_wait_server
+from rpent.utils.config import get_memory_dir, get_repo_root
+from rpent.utils.daemon import ProcessDaemon, pick_free_port
+from rpent.utils.rpc import make_rpc_client
+from rpent.utils.rpc.http_rpc import HttpRpcClient
+
+if TYPE_CHECKING:
+ from rpent.utils.rpc import RpcClient
+
+BEHAVIOR_MODES = ("eval", "explore")
+BEHAVIOR_COMPONENTS = {"env", "vla", "dino"}
+DEFAULT_EVAL_COMPONENTS = {"env", "vla", "dino"}
+DEFAULT_MAX_EPISODE_STEPS = 43_200
+DEFAULT_PLANNER_TIMEOUT_S = 7_200
+RLINF_ROOT_ENV = "RPENT_RLINF_ROOT"
+BEHAVIOR_PYTHON_ENV = "RPENT_BEHAVIOR_PYTHON"
+
+
+def _default_behavior_repo() -> Path:
+ configured = os.environ.get(RLINF_ROOT_ENV)
+ if configured:
+ return Path(configured).expanduser()
+ return get_repo_root().parent / "RLinf"
+
+
+def _default_behavior_python(behavior_repo: Path) -> str:
+ configured = os.environ.get(BEHAVIOR_PYTHON_ENV)
+ if configured:
+ return str(Path(configured).expanduser())
+ return str(behavior_repo / ".venv-behavior" / "bin" / "python")
+
+
+def _single_cuda_device(value: Any) -> str | None:
+ if value in (None, ""):
+ return None
+ device = str(value)
+ if re.fullmatch(r"[0-9]+", device) is None:
+ raise ValueError("CUDA device must be a single physical GPU ordinal")
+ return device
+
+
+def _component_cuda_device(
+ args: argparse.Namespace,
+ component: str,
+) -> str | None:
+ if component == "env":
+ specific = getattr(args, "behavior_env_cuda_device", None)
+ elif component in {"vla", "dino"}:
+ specific = getattr(args, "behavior_model_cuda_device", None)
+ else:
+ raise ValueError(f"unsupported CUDA component: {component}")
+ return _single_cuda_device(
+ specific if specific not in (None, "") else getattr(args, "cuda_device", None)
+ )
+
+
+def _task_from_args(args: argparse.Namespace) -> BehaviorTaskSpec:
+ task_name = getattr(args, "task_name", None)
+ task_value = getattr(args, "task", None)
+ if task_name:
+ spec = get_task_spec(str(task_name))
+ if task_value is not None and str(task_value).strip().isdigit():
+ by_index = get_task_spec_by_index(int(task_value))
+ if by_index is not spec:
+ raise ValueError(
+ f"BEHAVIOR task identity mismatch: {task_name!r} != {task_value!r}"
+ )
+ return spec
+ if task_value is None:
+ raise ValueError("--task-name is required")
+ if isinstance(task_value, str) and not task_value.strip().isdigit():
+ return get_task_spec(task_value.strip())
+ return get_task_spec_by_index(int(task_value))
+
+
+def _public_seed_from_args(args: argparse.Namespace) -> int:
+ public_seed = getattr(args, "public_seed", None)
+ seed = getattr(args, "seed", None)
+ if public_seed is not None and seed is not None and int(public_seed) != int(seed):
+ raise ValueError("--public-seed and --seed disagree")
+ value = public_seed if public_seed is not None else seed
+ if value is None:
+ raise ValueError("--public-seed is required")
+ if isinstance(value, bool):
+ raise ValueError("public seed must be an integer")
+ return int(value)
+
+
+def add_cli_args(parser: argparse.ArgumentParser, use_dashboard: bool) -> None:
+ required = not use_dashboard
+ parser.set_defaults(planner="codex", planner_timeout_s=DEFAULT_PLANNER_TIMEOUT_S)
+ parser.set_defaults(memory_profile="local")
+ parser.add_argument(
+ "--task-name",
+ required=required,
+ choices=("turning_on_radio", "picking_up_trash"),
+ )
+ parser.add_argument(
+ "--task",
+ default=None,
+ help="Task name or task index alias; task-name is preferred.",
+ )
+ parser.add_argument("--public-seed", type=int, required=required)
+ parser.add_argument(
+ "--seed",
+ type=int,
+ default=None,
+ help="Alias for --public-seed for dashboard and legacy launchers.",
+ )
+ parser.add_argument(
+ "--behavior-mode",
+ choices=BEHAVIOR_MODES,
+ default="eval",
+ help="BEHAVIOR prompt/runtime mode: eval or explore.",
+ )
+ parser.add_argument(
+ "--auto-merge-memory",
+ action=argparse.BooleanOptionalAction,
+ default=True,
+ help="Merge exploration output into layered memory (default: enabled).",
+ )
+ parser.add_argument(
+ "--explore-attempts-per-session",
+ type=int,
+ default=0,
+ help="Unsupported for BEHAVIOR; use --explore-sessions instead.",
+ )
+ parser.add_argument(
+ "--explore-sessions",
+ type=int,
+ default=1,
+ help="Fresh one-attempt BEHAVIOR sessions per exploration run.",
+ )
+ parser.add_argument(
+ "--max-episode-steps",
+ type=int,
+ default=DEFAULT_MAX_EPISODE_STEPS,
+ )
+ parser.add_argument("--env-endpoint", default=None)
+ parser.add_argument("--vla-endpoint", default=None)
+ parser.add_argument("--dino-endpoint", default=None)
+ default_behavior_repo = _default_behavior_repo()
+ parser.add_argument(
+ "--behavior-repo",
+ default=str(default_behavior_repo),
+ help=(
+ "Source checkout containing the RLinf BEHAVIOR integration. "
+ f"Can also be set with {RLINF_ROOT_ENV}."
+ ),
+ )
+ parser.add_argument(
+ "--behavior-python",
+ default=_default_behavior_python(default_behavior_repo),
+ help=(
+ "Python executable for the official BEHAVIOR/OmniGibson env process. "
+ f"Can also be set with {BEHAVIOR_PYTHON_ENV}."
+ ),
+ )
+ parser.add_argument(
+ "--activity-instance-dir",
+ default=None,
+ help="Optional explicit official BEHAVIOR task-instance JSON directory.",
+ )
+ parser.add_argument(
+ "--env-config-path",
+ default=None,
+ help="Optional explicit RLinf BEHAVIOR env YAML; the bundled adapter otherwise resolves the pinned template.",
+ )
+ parser.add_argument(
+ "--policy-checkpoint",
+ default=str(SHARED_POLICY_CHECKPOINT_PATH),
+ help=(
+ "Path to your Pi05-Behavior model checkpoint. "
+ f"Can also be set with {POLICY_CHECKPOINT_ENV}."
+ ),
+ )
+ parser.add_argument(
+ "--cuda-device",
+ default=None,
+ help=(
+ "Shared fallback physical GPU ordinal. Component-specific BEHAVIOR "
+ "GPU flags take precedence."
+ ),
+ )
+ parser.add_argument(
+ "--behavior-env-cuda-device",
+ default=None,
+ help="Physical GPU ordinal exposed only to the BEHAVIOR env process.",
+ )
+ parser.add_argument(
+ "--behavior-model-cuda-device",
+ default=None,
+ help="Physical GPU ordinal shared by the BEHAVIOR VLA and DINO processes.",
+ )
+ parser.add_argument(
+ "--behavior-memory-dir",
+ default=None,
+ help="Explicit DINO episode-memory catalog root. Omission selects a legal empty catalog.",
+ )
+ parser.add_argument("--dino-source-archive", default=None)
+ parser.add_argument("--dino-weights", default=None)
+ parser.add_argument("--dino-cache-dir", default=None)
+ parser.add_argument("--vla-ready-timeout-s", type=float, default=900.0)
+
+
+def parse_config(args: argparse.Namespace) -> RunConfig:
+ mode = str(getattr(args, "behavior_mode", None) or "eval")
+ if mode not in BEHAVIOR_MODES:
+ raise ValueError(f"unsupported --behavior-mode {mode!r}")
+ if getattr(args, "explore", False):
+ if mode != "explore":
+ raise ValueError("BEHAVIOR --explore requires --behavior-mode explore")
+ if getattr(args, "dashboard", False):
+ raise ValueError(
+ "BEHAVIOR --explore is CLI-only; use --behavior-mode explore "
+ "without --explore for Dashboard TaskRuns"
+ )
+ if getattr(args, "env_endpoint", None) is not None:
+ raise ValueError(
+ "BEHAVIOR explore requires an owned env sidecar; omit --env-endpoint"
+ )
+ if getattr(args, "explore_attempts_per_session", 0) > 0:
+ raise ValueError(
+ "BEHAVIOR explore runs one attempt per session; use --explore-sessions"
+ )
+ spec = _task_from_args(args)
+ public_seed = _public_seed_from_args(args)
+ activity_instance_id = spec.instance_for_public_seed(public_seed, phase=mode)
+ if getattr(args, "activity_instance_id", None) is not None:
+ requested = int(args.activity_instance_id)
+ if requested != activity_instance_id:
+ raise ValueError(
+ "activity_instance_id must match the task public-seed mapping: "
+ f"expected {activity_instance_id}, got {requested}"
+ )
+ cuda_device = _single_cuda_device(getattr(args, "cuda_device", None))
+ env_cuda_device = _component_cuda_device(args, "env")
+ model_cuda_device = _component_cuda_device(args, "vla")
+ if int(getattr(args, "max_episode_steps", 0) or 0) <= 0:
+ raise ValueError("--max-episode-steps must be positive")
+
+ args.task_name = spec.task_name
+ args.task = spec.task_index
+ args.public_seed = public_seed
+ args.seed = public_seed
+ args.behavior_phase = mode
+ args.activity_definition_id = spec.activity_definition_id
+ args.activity_instance_id = activity_instance_id
+ args.scene_model = spec.scene_model
+ args.cuda_device = cuda_device
+ args.behavior_env_cuda_device = env_cuda_device
+ args.behavior_model_cuda_device = model_cuda_device
+
+ recipe_tag = spec.tag(public_seed)
+ output_dir = getattr(args, "output_dir", None)
+ if output_dir is None:
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
+ output_dir = get_repo_root() / "logs" / f"{timestamp}_behavior_{recipe_tag}"
+ output_dir = Path(output_dir).expanduser().resolve()
+ requested_memory_profile = getattr(args, "memory_profile", None)
+ memory_profile = str(requested_memory_profile or "local")
+ if memory_profile != "local":
+ raise ValueError("BEHAVIOR requires --memory-profile local")
+ configured_memory_dir = getattr(args, "memory_dir", None)
+ memory_dir = (
+ Path(configured_memory_dir).expanduser().resolve()
+ if configured_memory_dir
+ else get_memory_dir("behavior").resolve()
+ )
+ args.memory_profile = memory_profile
+ args.memory_dir = str(memory_dir)
+ configured_episode_memory_dir = getattr(args, "behavior_memory_dir", None)
+ episode_memory_dir = (
+ Path(configured_episode_memory_dir).expanduser().resolve()
+ if configured_episode_memory_dir
+ else None
+ )
+ args.behavior_memory_dir = (
+ str(episode_memory_dir) if episode_memory_dir is not None else None
+ )
+ args.behavior_memory_dir_explicit = episode_memory_dir is not None
+ episode_memory_profile = (
+ "explicit" if episode_memory_dir is not None else "empty_episode_catalog"
+ )
+ return RunConfig(
+ recipe_tag=recipe_tag,
+ output_dir=output_dir,
+ prompt_vars={
+ "task_name": spec.task_name,
+ "task": spec.task_index,
+ "task_language": spec.task_language,
+ "instruction": spec.task_language,
+ "task_instruction": spec.task_language,
+ "public_seed": public_seed,
+ "activity_definition_id": spec.activity_definition_id,
+ "activity_instance_id": activity_instance_id,
+ "scene_model": spec.scene_model,
+ "behavior_mode": mode,
+ "behavior_phase": mode,
+ "max_episode_steps": int(args.max_episode_steps),
+ "wall_clock_seconds": int(getattr(args, "planner_timeout_s", 7200) or 7200),
+ "public_capabilities": [
+ item["name"] for item in behavior_tool_specs_for_task(spec)
+ ],
+ "memory_dir": str(memory_dir),
+ "memory_profile": memory_profile,
+ "memory_inbox": str(memory_dir / "_internal" / "inbox" / recipe_tag),
+ "behavior_episode_memory": episode_memory_profile,
+ "behavior_memory_dir": str(episode_memory_dir)
+ if episode_memory_dir is not None
+ else "",
+ "recipe_tag": recipe_tag,
+ "output_dir": str(output_dir),
+ },
+ task_desc={
+ "env": "behavior",
+ "task_name": spec.task_name,
+ "task": spec.task_index,
+ "public_seed": public_seed,
+ "activity_definition_id": spec.activity_definition_id,
+ "activity_instance_id": activity_instance_id,
+ "scene_model": spec.scene_model,
+ "mapping_version": spec.mapping_version,
+ "behavior_mode": mode,
+ "policy_profile_id": SHARED_POLICY_PROFILE_ID,
+ "action_dim": ACTION_DIM,
+ "action_horizon": DEFAULT_ACTION_CHUNK,
+ "cuda_device": cuda_device,
+ "behavior_env_cuda_device": env_cuda_device,
+ "behavior_model_cuda_device": model_cuda_device,
+ "memory_profile": memory_profile,
+ "memory_dir": str(memory_dir),
+ "behavior_episode_memory": episode_memory_profile,
+ "behavior_memory_dir_explicit": episode_memory_dir is not None,
+ "behavior_memory_dir": str(episode_memory_dir)
+ if episode_memory_dir is not None
+ else None,
+ },
+ )
+
+
+def env_runtime_contract(args: argparse.Namespace) -> dict[str, Any]:
+ return {
+ "runtime": "behavior_env",
+ "task_name": args.task_name,
+ "task": int(args.task),
+ "task_language": get_task_spec(args.task_name).task_language,
+ "activity_definition_id": int(args.activity_definition_id),
+ "activity_instance_id": int(args.activity_instance_id),
+ "public_seed": int(args.public_seed),
+ "scene_model": str(args.scene_model),
+ "max_episode_steps": int(args.max_episode_steps),
+ "action_dim": ACTION_DIM,
+ "action_horizon": DEFAULT_ACTION_CHUNK,
+ "official_success_path": ["info", "done", "success"],
+ "behavior_repo": str(Path(args.behavior_repo).expanduser().resolve()),
+ "activity_instance_dir": (
+ None
+ if not getattr(args, "activity_instance_dir", None)
+ else str(Path(args.activity_instance_dir).expanduser().resolve())
+ ),
+ "rlinf_env_config_path": (
+ None
+ if not getattr(args, "env_config_path", None)
+ else str(Path(args.env_config_path).expanduser().resolve())
+ ),
+ }
+
+
+def _behavior_python_path(value: str | Path) -> Path:
+ """Return an absolute executable path without dereferencing venv symlinks."""
+
+ return Path(value).expanduser().absolute()
+
+
+def _behavior_subprocess_env(
+ *,
+ cuda_device: str | None = None,
+ **overrides: str,
+) -> dict[str, str]:
+ repo_root = str(get_repo_root())
+ pythonpath = os.environ.get("PYTHONPATH")
+ return {
+ **overrides,
+ "PYTHONPATH": (
+ repo_root if not pythonpath else os.pathsep.join([repo_root, pythonpath])
+ ),
+ **({"CUDA_VISIBLE_DEVICES": cuda_device} if cuda_device is not None else {}),
+ }
+
+
+def vla_runtime_contract(args: argparse.Namespace) -> dict[str, Any]:
+ return {
+ "runtime": "pi05_vla",
+ "embodiment": "behavior",
+ "config_name": "pi05_behavior",
+ "action_dim": ACTION_DIM,
+ "action_horizon": DEFAULT_ACTION_CHUNK,
+ "policy_profile_id": SHARED_POLICY_PROFILE_ID,
+ "checkpoint": str(Path(args.policy_checkpoint).expanduser()),
+ }
+
+
+def _wait_for_server_endpoint(
+ daemon: ProcessDaemon,
+ *,
+ log_offset: int = 0,
+ timeout_s: float = 1800.0,
+) -> str:
+ """Read this launch's bound RPC address, never a preselected free port.
+
+ ProcessDaemon appends logs, so callers supply the pre-launch byte offset.
+ Application readiness is still checked by the shared RPC health probe.
+ """
+ if daemon.log_path is None:
+ raise ValueError("endpoint discovery requires a daemon log_path")
+ deadline = time.monotonic() + timeout_s
+ with Path(daemon.log_path).open("rb") as log:
+ log.seek(log_offset)
+ while time.monotonic() < deadline:
+ exit_code = daemon.poll()
+ if exit_code is not None:
+ raise RuntimeError(
+ f"{daemon.name} exited with code {exit_code}; see {daemon.log_path}"
+ )
+ position = log.tell()
+ line = log.readline()
+ if not line.endswith(b"\n"):
+ log.seek(position)
+ time.sleep(0.1)
+ continue
+ match = re.fullmatch(
+ rb"RPC server listening on (http://[^\s:]+:[1-9][0-9]*)\r?\n",
+ line,
+ )
+ if match is not None:
+ return match.group(1).decode("ascii")
+ raise TimeoutError(
+ f"{daemon.name} did not announce an RPC endpoint within {timeout_s}s; "
+ f"see {daemon.log_path}"
+ )
+
+
+def _spawn_env_server(
+ args: argparse.Namespace,
+ output_dir: Path,
+) -> tuple[ProcessDaemon | None, "RpcClient"]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ if args.env_endpoint is not None:
+ return None, make_rpc_client(args.env_endpoint)
+ # Bind only after Ray/OmniGibson initialization; the OS chooses the port.
+ host, port = "127.0.0.1", 0
+ cuda_device = _component_cuda_device(args, "env")
+ # Keep the virtualenv launcher path intact. Resolving ``bin/python``
+ # follows its symlink to the system interpreter and silently drops the
+ # virtualenv's site-packages (OmniGibson, OpenPI, OmegaConf, ...).
+ behavior_python = _behavior_python_path(args.behavior_python)
+ if not behavior_python.is_file():
+ raise RuntimeError(f"BEHAVIOR Python executable is missing: {behavior_python}")
+ cmd = [
+ str(behavior_python),
+ str(get_repo_root() / "robots" / "behavior" / "env_server.py"),
+ "--task-name",
+ str(args.task_name),
+ "--public-seed",
+ str(args.public_seed),
+ "--task-index",
+ str(args.task),
+ "--activity-definition-id",
+ str(args.activity_definition_id),
+ "--activity-instance-id",
+ str(args.activity_instance_id),
+ "--scene-model",
+ str(args.scene_model),
+ "--max-episode-steps",
+ str(args.max_episode_steps),
+ "--output-dir",
+ str(output_dir),
+ "--behavior-repo",
+ str(Path(args.behavior_repo).expanduser().resolve()),
+ "--host",
+ host,
+ "--port",
+ str(port),
+ "--parent-watch",
+ ]
+ if getattr(args, "activity_instance_dir", None):
+ cmd.extend(
+ [
+ "--activity-instance-dir",
+ str(Path(args.activity_instance_dir).expanduser().resolve()),
+ ]
+ )
+ if getattr(args, "env_config_path", None):
+ cmd.extend(
+ [
+ "--env-config-path",
+ str(Path(args.env_config_path).expanduser().resolve()),
+ ]
+ )
+ if cuda_device is not None:
+ cmd.extend(["--cuda-device", cuda_device])
+ daemon = ProcessDaemon(
+ name="behavior_env_server",
+ cmd=cmd,
+ env_overrides=_behavior_subprocess_env(
+ cuda_device=cuda_device,
+ **{
+ "ROBOT_PLATFORM": "BEHAVIOR",
+ "OMNIGIBSON_HEADLESS": "1",
+ # Ray otherwise clears CUDA_VISIBLE_DEVICES for the zero-GPU actor
+ # that owns the single OmniGibson subprocess.
+ "RAY_ACCEL_ENV_VAR_OVERRIDE_ON_ZERO": "0",
+ },
+ ),
+ log_path=str(output_dir / "behavior_env_server.log"),
+ )
+ log_path = Path(daemon.log_path)
+ log_offset = log_path.stat().st_size if log_path.exists() else 0
+ daemon.start()
+ try:
+ endpoint = _wait_for_server_endpoint(daemon, log_offset=log_offset)
+ return daemon, HttpRpcClient(endpoint)
+ except BaseException:
+ # try_spawn_server cannot own this daemon until this function returns.
+ daemon.stop()
+ raise
+
+
+def _spawn_vla_server(
+ args: argparse.Namespace,
+ output_dir: Path,
+) -> tuple[ProcessDaemon | None, "RpcClient"]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ if args.vla_endpoint is not None:
+ return None, make_rpc_client(args.vla_endpoint)
+ host, port = "127.0.0.1", pick_free_port()
+ cuda_device = _component_cuda_device(args, "vla")
+ # See _spawn_env_server: do not dereference a virtualenv's python symlink.
+ behavior_python = _behavior_python_path(args.behavior_python)
+ if not behavior_python.is_file():
+ raise RuntimeError(f"BEHAVIOR Python executable is missing: {behavior_python}")
+ checkpoint_manifest = output_dir / "policy_checkpoint_manifest.json"
+ write_policy_checkpoint_manifest(
+ checkpoint_manifest,
+ Path(args.policy_checkpoint).expanduser(),
+ )
+ cmd = [
+ str(behavior_python),
+ str(get_repo_root() / "rpent" / "robots" / "components" / "pi05_vla_server.py"),
+ "--embodiment",
+ "behavior",
+ "--transport",
+ "http",
+ "--host",
+ host,
+ "--port",
+ str(port),
+ "--model-path",
+ str(Path(args.policy_checkpoint).expanduser()),
+ "--checkpoint-manifest",
+ str(checkpoint_manifest),
+ "--parent-watch",
+ ]
+ if cuda_device is not None:
+ cmd.extend(["--cuda-device", cuda_device])
+ daemon = ProcessDaemon(
+ name="behavior_vla_server",
+ cmd=cmd,
+ env_overrides=_behavior_subprocess_env(cuda_device=cuda_device),
+ log_path=str(output_dir / "behavior_vla_server.log"),
+ )
+ daemon.start()
+ return daemon, make_rpc_client(f"http://{host}:{port}")
+
+
+def _spawn_dino_server(
+ args: argparse.Namespace,
+ output_dir: Path,
+) -> tuple[ProcessDaemon | None, "RpcClient"]:
+ output_dir.mkdir(parents=True, exist_ok=True)
+ if args.dino_endpoint is not None:
+ return None, make_rpc_client(args.dino_endpoint)
+ host, port = "127.0.0.1", pick_free_port()
+ cuda_device = _component_cuda_device(args, "dino")
+ behavior_python = _behavior_python_path(args.behavior_python)
+ if not behavior_python.is_file():
+ raise RuntimeError(f"BEHAVIOR Python executable is missing: {behavior_python}")
+ cmd = [
+ str(behavior_python),
+ str(get_repo_root() / "robots" / "behavior" / "dino_v2" / "server.py"),
+ "--host",
+ host,
+ "--port",
+ str(port),
+ "--parent-watch",
+ ]
+ if getattr(args, "dino_source_archive", None):
+ cmd.extend(
+ [
+ "--source-archive",
+ str(Path(args.dino_source_archive).expanduser().resolve()),
+ ]
+ )
+ if getattr(args, "dino_weights", None):
+ cmd.extend(["--weights", str(Path(args.dino_weights).expanduser().resolve())])
+ if getattr(args, "dino_cache_dir", None):
+ cmd.extend(
+ ["--cache-dir", str(Path(args.dino_cache_dir).expanduser().resolve())]
+ )
+ if cuda_device is not None:
+ cmd.extend(["--cuda-device", cuda_device])
+ daemon = ProcessDaemon(
+ name="behavior_dino_server",
+ cmd=cmd,
+ env_overrides=_behavior_subprocess_env(cuda_device=cuda_device),
+ log_path=str(output_dir / "behavior_dino_server.log"),
+ )
+ daemon.start()
+ return daemon, HttpRpcClient(f"http://{host}:{port}")
+
+
+def _connect_env(
+ args: argparse.Namespace,
+ rpc: "RpcClient",
+ output_dir: Path,
+) -> dict[str, Any]:
+ from robots.behavior.env_client import BehaviorEnvClient
+
+ env = BehaviorEnvClient(rpc, expected_meta=env_runtime_contract(args))
+ initial_observation, initial_info = env.reset()
+ task_language = initial_observation.get("task_descriptions")
+ if isinstance(task_language, (list, tuple)):
+ task_language = next(
+ (item for item in task_language if isinstance(item, str)), None
+ )
+ if task_language is not None and str(task_language).strip():
+ expected = get_task_spec(args.task_name).task_language
+ if str(task_language).strip() != expected:
+ raise RuntimeError("environment task language does not match TaskSpec")
+ return {
+ "env": env,
+ "task_name": args.task_name,
+ "behavior_phase": args.behavior_phase,
+ "public_seed": int(args.public_seed),
+ "max_episode_steps": int(args.max_episode_steps),
+ "action_horizon": DEFAULT_ACTION_CHUNK,
+ "initial_observation": initial_observation,
+ "initial_info": initial_info,
+ "output_dir": Path(output_dir),
+ }
+
+
+def _connect_vla(args: argparse.Namespace, rpc: "RpcClient") -> dict[str, Any]:
+ from robots.behavior.policy_checkpoint import (
+ assert_matching_policy_checkpoint_binding,
+ validate_policy_checkpoint,
+ )
+ from rpent.robots.components.pi05_vla_client import Pi05VLAClient
+
+ expected_binding = validate_policy_checkpoint(args.policy_checkpoint)
+ server_meta = rpc.call(
+ "healthz",
+ timeout_s=min(float(getattr(args, "vla_ready_timeout_s", 900.0)), 30.0),
+ )
+ if not isinstance(server_meta, dict):
+ raise TypeError("VLA healthz must return a mapping")
+ assert_matching_policy_checkpoint_binding(
+ server_meta.get("checkpoint_binding"),
+ expected_binding,
+ )
+ return {
+ "model": Pi05VLAClient(rpc, embodiment="behavior"),
+ }
+
+
+def _connect_dino(args: argparse.Namespace, rpc: "RpcClient") -> dict[str, Any]:
+ from robots.behavior.dino_v2.client import BehaviorDinoClient
+ from robots.behavior.memory.index import load_current_catalog
+
+ client = BehaviorDinoClient(rpc, expected_meta={"runtime": "behavior_dino"})
+ configured_memory_dir = getattr(args, "behavior_memory_dir", None)
+ explicit_marker = getattr(args, "behavior_memory_dir_explicit", None)
+ explicit = (
+ bool(configured_memory_dir)
+ if explicit_marker is None
+ else bool(explicit_marker)
+ )
+ if explicit and not configured_memory_dir:
+ raise ValueError("explicit BEHAVIOR episode-memory catalog path is missing")
+ memory_dir = (
+ Path(configured_memory_dir).expanduser().resolve() if explicit else None
+ )
+ return {
+ "dino_component": client,
+ "episode_memory_index": load_current_catalog(memory_dir),
+ }
+
+
+def init_runtime(
+ args: argparse.Namespace,
+ output_dir: Path,
+ dashboard_events: DashboardEventSink,
+ components: set[str] | None,
+) -> tuple[list[ProcessDaemon], dict[str, Any]]:
+ """Initialize requested BEHAVIOR components under the RobotSpec contract."""
+
+ selected = set(DEFAULT_EVAL_COMPONENTS if components is None else components)
+ if components is None and getattr(args, "explore", False):
+ # The CLI session hook owns a fresh ENV; VLA/DINO survive handoffs.
+ selected.discard("env")
+ unknown = selected.difference(BEHAVIOR_COMPONENTS)
+ if unknown:
+ raise ValueError(f"unknown BEHAVIOR runtime components: {sorted(unknown)}")
+
+ owned_daemons: dict[str, ProcessDaemon] = {}
+ primitives_kwargs: dict[str, Any] = {}
+ pending_env: tuple[ProcessDaemon | None, RpcClient] | None = None
+ pending_vla: tuple[ProcessDaemon | None, RpcClient] | None = None
+ pending_dino: tuple[ProcessDaemon | None, RpcClient] | None = None
+ if "env" in selected:
+ pending_env = try_spawn_server(
+ owned_daemons,
+ dashboard_events,
+ "env",
+ lambda: _spawn_env_server(args, output_dir),
+ )
+ if "vla" in selected:
+ pending_vla = try_spawn_server(
+ owned_daemons,
+ dashboard_events,
+ "vla",
+ lambda: _spawn_vla_server(args, output_dir),
+ )
+ if "dino" in selected:
+ pending_dino = try_spawn_server(
+ owned_daemons,
+ dashboard_events,
+ "dino",
+ lambda: _spawn_dino_server(args, output_dir),
+ )
+ if pending_env is not None:
+ daemon, rpc = pending_env
+ primitives_kwargs.update(
+ try_wait_server(
+ owned_daemons,
+ dashboard_events,
+ "env",
+ rpc,
+ daemon,
+ 1800.0 if daemon is not None else 120.0,
+ post_fn=lambda: _connect_env(args, rpc, output_dir),
+ )
+ )
+ if pending_vla is not None:
+ daemon, rpc = pending_vla
+ primitives_kwargs.update(
+ try_wait_server(
+ owned_daemons,
+ dashboard_events,
+ "vla",
+ rpc,
+ daemon,
+ float(getattr(args, "vla_ready_timeout_s", 900.0)),
+ post_fn=lambda: _connect_vla(args, rpc),
+ )
+ )
+ if pending_dino is not None:
+ daemon, rpc = pending_dino
+ primitives_kwargs.update(
+ try_wait_server(
+ owned_daemons,
+ dashboard_events,
+ "dino",
+ rpc,
+ daemon,
+ 600.0 if daemon is not None else 120.0,
+ post_fn=lambda: _connect_dino(args, rpc),
+ )
+ )
+ return list(owned_daemons.values()), primitives_kwargs
+
+
+def on_explore_session(
+ args: argparse.Namespace,
+ state_output_dir: Path,
+ dashboard_events: DashboardEventSink,
+ daemons: list[ProcessDaemon],
+) -> dict[str, Any]:
+ """Replace only this run's ENV sidecar for a fresh Explore episode."""
+ for daemon in tuple(daemons):
+ if daemon.name == "behavior_env_server":
+ stop_owned_daemons({"env": daemon}, dashboard_events)
+ daemons.remove(daemon)
+ env_daemons, env_kwargs = init_runtime(
+ args, state_output_dir, dashboard_events, {"env"}
+ )
+ daemons.extend(env_daemons)
+ if len(env_daemons) != 1:
+ raise RuntimeError("BEHAVIOR explore requires one owned env daemon per session")
+ return env_kwargs
+
+
+__all__ = [
+ "BEHAVIOR_COMPONENTS",
+ "BEHAVIOR_MODES",
+ "DEFAULT_EVAL_COMPONENTS",
+ "DEFAULT_MAX_EPISODE_STEPS",
+ "DEFAULT_PLANNER_TIMEOUT_S",
+ "add_cli_args",
+ "env_runtime_contract",
+ "init_runtime",
+ "on_explore_session",
+ "parse_config",
+ "vla_runtime_contract",
+]
diff --git a/robots/behavior/schemas.py b/robots/behavior/schemas.py
new file mode 100644
index 000000000..73dcfaaae
--- /dev/null
+++ b/robots/behavior/schemas.py
@@ -0,0 +1,848 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Validated BEHAVIOR/R1Pro observation, action, and public tool contracts."""
+
+from __future__ import annotations
+
+import copy
+import math
+from collections.abc import Mapping
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.task_specs import BehaviorTaskSpec, get_task_spec
+
+ACTION_DIM = 23
+DEFAULT_ACTION_CHUNK = 32
+CAMERA_KEYS = ("main", "left_wrist", "right_wrist")
+PHYSICAL_CAMERAS = ("head", "left_wrist", "right_wrist")
+HEAD_VIEW_PRESETS = (
+ "center",
+ "up",
+ "down",
+ "left",
+ "right",
+ "down_left",
+ "down_right",
+)
+FRAME_REVIEW_ASSESSMENTS = (
+ "target_bearing_surface_confirmed",
+ "opposite_surface_confirmed",
+ "side_or_indeterminate",
+)
+
+PUBLIC_TOOL_CONTRACTS: dict[int, tuple[str, ...]] = {
+ 1: (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ "save_robot_state_checkpoint",
+ ),
+ 2: (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ "save_robot_state_checkpoint",
+ "navigate_to",
+ ),
+ 3: (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ "save_robot_state_checkpoint",
+ "navigate_to",
+ "move_both_to",
+ ),
+ 4: (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ "save_robot_state_checkpoint",
+ "navigate_to",
+ "move_both_to",
+ "get_prepared_motion_status",
+ ),
+ 5: (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "navigate_to",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ ),
+}
+CURRENT_PUBLIC_TOOL_CONTRACT_VERSION = 5
+BEHAVIOR_TOOL_NAMES = PUBLIC_TOOL_CONTRACTS[CURRENT_PUBLIC_TOOL_CONTRACT_VERSION]
+PUBLIC_PRIMITIVE_ENTRYPOINTS = {
+ "pi0_nav_pick": "BehaviorPrimitives.pi0_nav_pick",
+ "observe": "BehaviorPrimitives.observe",
+ "pixel_to_world": "BehaviorPrimitives.pixel_to_world",
+ "navigate_to": "BehaviorPrimitives.navigate_to",
+ "move_to": "BehaviorPrimitives.move_to",
+ "rotate_wrist": "BehaviorPrimitives.rotate_wrist",
+ "close": "BehaviorPrimitives.close",
+ "open": "BehaviorPrimitives.open",
+ "press": "BehaviorPrimitives.press",
+}
+if tuple(PUBLIC_TOOL_CONTRACTS) != (1, 2, 3, 4, 5):
+ raise ValueError("BEHAVIOR public tool contract versions must be contiguous")
+if tuple(PUBLIC_PRIMITIVE_ENTRYPOINTS) != BEHAVIOR_TOOL_NAMES:
+ raise ValueError("BEHAVIOR primitive entrypoints must match the public contract")
+if len(BEHAVIOR_TOOL_NAMES) != 9 or len(set(BEHAVIOR_TOOL_NAMES)) != 9:
+ raise ValueError("BEHAVIOR toolkit must expose 9 unique public primitives")
+
+POLICY_STATE_SEGMENTS: dict[str, slice] = {
+ "base": slice(0, 3),
+ "trunk": slice(3, 7),
+ "left_arm": slice(7, 14),
+ "right_arm": slice(14, 21),
+ "left_gripper": slice(21, 22),
+ "right_gripper": slice(22, 23),
+}
+ENV_ACTION_SEGMENTS: dict[str, slice] = {
+ "base": slice(0, 3),
+ "trunk": slice(3, 7),
+ "left_arm": slice(7, 14),
+ "left_gripper": slice(14, 15),
+ "right_arm": slice(15, 22),
+ "right_gripper": slice(22, 23),
+}
+RAW_PROPRIO_SEGMENTS: dict[str, slice] = {
+ "left_arm": slice(158, 165),
+ "left_gripper": slice(193, 195),
+ "right_arm": slice(197, 204),
+ "right_gripper": slice(232, 234),
+ "trunk": slice(236, 240),
+ "base": slice(253, 256),
+}
+
+
+def _validate_segments(name: str, segments: Mapping[str, slice]) -> None:
+ covered: list[int] = []
+ for segment, indices in segments.items():
+ if (
+ indices.start is None
+ or indices.stop is None
+ or indices.step not in (None, 1)
+ ):
+ raise ValueError(f"{name}.{segment} must be a contiguous slice")
+ covered.extend(range(indices.start, indices.stop))
+ if covered != list(range(ACTION_DIM)):
+ raise ValueError(
+ f"{name} must cover 0..{ACTION_DIM - 1} exactly, got {covered}"
+ )
+
+
+_validate_segments("POLICY_STATE_SEGMENTS", POLICY_STATE_SEGMENTS)
+_validate_segments("ENV_ACTION_SEGMENTS", ENV_ACTION_SEGMENTS)
+if POLICY_STATE_SEGMENTS == ENV_ACTION_SEGMENTS:
+ raise ValueError("policy state and env action layouts must remain distinct")
+
+
+def segment_ranges(segments: Mapping[str, slice]) -> dict[str, list[int]]:
+ return {name: [part.start, part.stop] for name, part in segments.items()}
+
+
+def validate_policy_state(state: Any) -> np.ndarray:
+ array = np.asarray(state, dtype=np.float32)
+ if array.shape != (ACTION_DIM,):
+ raise ValueError(
+ f"compact policy state must be [{ACTION_DIM}], got {array.shape}"
+ )
+ if not np.isfinite(array).all():
+ raise ValueError("compact policy state contains NaN or infinity")
+ return array
+
+
+def extract_policy_state(raw_proprio: Any) -> np.ndarray:
+ raw = np.asarray(raw_proprio, dtype=np.float32)
+ if raw.ndim != 1 or raw.shape[0] < RAW_PROPRIO_SEGMENTS["base"].stop:
+ raise ValueError(
+ "raw R1Pro proprio must be a vector with at least "
+ f"{RAW_PROPRIO_SEGMENTS['base'].stop} values, got {raw.shape}"
+ )
+ compact = np.concatenate(
+ [
+ raw[RAW_PROPRIO_SEGMENTS["base"]],
+ raw[RAW_PROPRIO_SEGMENTS["trunk"]],
+ raw[RAW_PROPRIO_SEGMENTS["left_arm"]],
+ raw[RAW_PROPRIO_SEGMENTS["right_arm"]],
+ np.asarray([raw[RAW_PROPRIO_SEGMENTS["left_gripper"]].sum()]),
+ np.asarray([raw[RAW_PROPRIO_SEGMENTS["right_gripper"]].sum()]),
+ ]
+ )
+ return validate_policy_state(compact)
+
+
+def validate_action_chunk(
+ actions: Any, *, max_horizon: int | None = None
+) -> np.ndarray:
+ array = np.asarray(actions, dtype=np.float32)
+ if array.ndim != 2 or array.shape[1] != ACTION_DIM or array.shape[0] < 1:
+ raise ValueError(
+ f"BEHAVIOR actions must be [T,{ACTION_DIM}], got {array.shape}"
+ )
+ if not np.isfinite(array).all():
+ raise ValueError("BEHAVIOR actions contain NaN or infinity")
+ if max_horizon is not None and array.shape[0] > int(max_horizon):
+ raise ValueError(
+ f"BEHAVIOR action horizon {array.shape[0]} exceeds {int(max_horizon)}"
+ )
+ return array
+
+
+ENV_WIRE_SCHEMA: dict[str, Any] = {
+ "name": "behavior_env_rpc",
+ "version": 1,
+ "observation": {
+ "main_images": "uint8[H,W,3]",
+ "wrist_images": "uint8[2,H,W,3]",
+ "states": "float[raw_proprio_dim]",
+ "task_descriptions": "str",
+ },
+ "action": {
+ "shape": f"float[T,{ACTION_DIM}]",
+ "segments": segment_ranges(ENV_ACTION_SEGMENTS),
+ },
+ "official_success_path": ["info", "done", "success"],
+}
+
+VLA_WIRE_SCHEMA: dict[str, Any] = {
+ "name": "pi05_vla_rpc_behavior",
+ "version": 2,
+ "request": {
+ "method": "vla.predict",
+ "main_images": "uint8[1,H,W,3]",
+ "wrist_images": "uint8[1,2,H,W,3]",
+ "states": "float[1,raw_proprio_dim]",
+ "task_descriptions": "list[str]",
+ "extra_view_images": "None",
+ "compact_state_segments": segment_ranges(POLICY_STATE_SEGMENTS),
+ "mode": "eval",
+ },
+ "response": {
+ "actions": f"float[1,T,{ACTION_DIM}]",
+ "env_action_segments": segment_ranges(ENV_ACTION_SEGMENTS),
+ },
+}
+
+
+def _planner_spec(
+ name: str,
+ description: str,
+ properties: dict[str, Any],
+ *,
+ required: list[str] | None = None,
+ one_of: list[dict[str, Any]] | None = None,
+) -> dict[str, Any]:
+ schema = {
+ "type": "object",
+ "properties": properties,
+ "required": required or [],
+ "additionalProperties": False,
+ }
+ if one_of is not None:
+ schema["oneOf"] = one_of
+ return {"name": name, "description": description, "input_schema": schema}
+
+
+_CAMERA_ROLE_SCHEMA = {
+ "type": "string",
+ "enum": ["head", "left_wrist", "right_wrist"],
+}
+_HAND_SCHEMA = {"type": "string", "enum": ["left", "right"]}
+_VISUAL_HAND_CHECK_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "camera": {"type": "string", "enum": ["head", "left_wrist", "right_wrist"]},
+ "frame_id": {"type": "string", "minLength": 1},
+ "selected_hand": {"type": "string", "enum": ["left", "right"]},
+ "assessment": {"type": "string", "const": "selected_hand_visually_confirmed"},
+ },
+ "required": ["camera", "frame_id", "selected_hand", "assessment"],
+ "additionalProperties": False,
+}
+_RELEASE_VISUAL_CHECK_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "camera": {"type": "string", "enum": ["head", "left_wrist", "right_wrist"]},
+ "frame_id": {"type": "string", "minLength": 1},
+ "selected_hand": {"type": "string", "enum": ["left", "right"]},
+ "assessment": {
+ "type": "string",
+ "const": "attached_object_fully_inside_receptacle_opening",
+ },
+ },
+ "required": ["camera", "frame_id", "selected_hand", "assessment"],
+ "additionalProperties": False,
+}
+_DELTA_XYZ_SCHEMA = {
+ "type": "array",
+ "items": {"type": "number"},
+ "minItems": 3,
+ "maxItems": 3,
+}
+
+PI0_NAV_PICK_SPEC = _planner_spec(
+ "pi0_nav_pick",
+ (
+ "Invoke a Pi0.5 planner tool supporting navigation, grasping, and "
+ "pressing. chunks is a positive requested work bound with no fixed "
+ "maximum; execution is bounded only by the remaining episode steps, raw "
+ "official success, termination, truncation, or infrastructure failure. "
+ "task_success reports only raw info.done.success."
+ ),
+ {
+ "instruction": {
+ "type": "string",
+ "minLength": 1,
+ "description": "Exact VLA task language for this invocation.",
+ },
+ "chunks": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Positive [32,23] action chunk request count.",
+ },
+ },
+ required=["instruction", "chunks"],
+)
+
+OBSERVE_SPEC = _planner_spec(
+ "observe",
+ "Capture or review a public BEHAVIOR RGB-D observation without advancing physics.",
+ {
+ "camera": _CAMERA_ROLE_SCHEMA,
+ "head_view": {"type": "string", "enum": list(HEAD_VIEW_PRESETS)},
+ "paired_hand": _HAND_SCHEMA,
+ "frame_review": {
+ "type": "object",
+ "properties": {
+ "frame_id": {"type": "string", "minLength": 1},
+ "assessment": {
+ "type": "string",
+ "enum": list(FRAME_REVIEW_ASSESSMENTS),
+ },
+ },
+ "required": ["frame_id", "assessment"],
+ "additionalProperties": False,
+ },
+ "depth_probe": {
+ "type": "object",
+ "properties": {
+ "frame_id": {"type": "string", "minLength": 1},
+ "u": {"type": "integer"},
+ "v": {"type": "integer"},
+ "depth_window_px": {"type": "integer", "minimum": 1, "maximum": 31},
+ "assessment": {
+ "type": "string",
+ "const": "target_point_visually_confirmed",
+ },
+ },
+ "required": ["frame_id", "u", "v", "depth_window_px", "assessment"],
+ "additionalProperties": False,
+ },
+ },
+ required=["camera"],
+)
+OBSERVE_SPEC["input_schema"]["allOf"] = [
+ {"not": {"required": ["frame_review", "depth_probe"]}},
+ {
+ "if": {"required": ["head_view"]},
+ "then": {
+ "properties": {"camera": {"const": "head"}},
+ "not": {
+ "anyOf": [
+ {"required": ["frame_review"]},
+ {"required": ["depth_probe"]},
+ ]
+ },
+ },
+ },
+ {
+ "if": {"required": ["paired_hand"]},
+ "then": {
+ "properties": {"camera": {"const": "head"}},
+ "not": {
+ "anyOf": [
+ {"required": ["frame_review"]},
+ {"required": ["depth_probe"]},
+ ]
+ },
+ },
+ },
+]
+
+PIXEL_TO_WORLD_SPEC = _planner_spec(
+ "pixel_to_world",
+ "Back-project one pixel from a fresh public RGB-D frame.",
+ {
+ "camera": _CAMERA_ROLE_SCHEMA,
+ "frame_id": {"type": "string", "minLength": 1},
+ "u": {"type": "integer"},
+ "v": {"type": "integer"},
+ "depth_window_px": {
+ "type": "integer",
+ "default": 7,
+ "minimum": 1,
+ "maximum": 31,
+ },
+ "target_fact": {"type": "string", "const": "soda_can_floor_outside_receptacle"},
+ },
+ required=["camera", "frame_id", "u", "v"],
+)
+
+_MOVE_TARGET_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "projection_id": {"type": "string", "minLength": 1},
+ "standoff_m": {"type": "number", "minimum": 0.0},
+ "delta_xyz": _DELTA_XYZ_SCHEMA,
+ "frame": {"type": "string", "enum": ["world", "eef"]},
+ },
+ "oneOf": [
+ {
+ "required": ["projection_id"],
+ "not": {
+ "anyOf": [
+ {"required": ["delta_xyz"]},
+ {"required": ["frame"]},
+ ]
+ },
+ },
+ {
+ "required": ["delta_xyz", "frame"],
+ "not": {
+ "anyOf": [
+ {"required": ["projection_id"]},
+ {"required": ["standoff_m"]},
+ ]
+ },
+ },
+ ],
+ "additionalProperties": False,
+}
+
+_MOVE_TO_HAND_SCHEMA = {"type": "string", "enum": ["left", "right", "both"]}
+_MOVE_TO_BOTH_TARGETS_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "left": {
+ "type": "object",
+ "properties": {
+ "delta_xyz": _DELTA_XYZ_SCHEMA,
+ "frame": {"type": "string", "enum": ["world", "eef"]},
+ },
+ "required": ["delta_xyz", "frame"],
+ "additionalProperties": False,
+ },
+ "right": {
+ "type": "object",
+ "properties": {
+ "delta_xyz": _DELTA_XYZ_SCHEMA,
+ "frame": {"type": "string", "enum": ["world", "eef"]},
+ },
+ "required": ["delta_xyz", "frame"],
+ "additionalProperties": False,
+ },
+ },
+ "required": ["left", "right"],
+ "additionalProperties": False,
+}
+_MOVE_TO_BOTH_VISUAL_HAND_CHECKS_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "left": _VISUAL_HAND_CHECK_SCHEMA,
+ "right": _VISUAL_HAND_CHECK_SCHEMA,
+ },
+ "required": ["left", "right"],
+ "additionalProperties": False,
+}
+
+MOVE_TO_SPEC = _planner_spec(
+ "move_to",
+ "Move the selected BEHAVIOR hand, or coordinate both hands when hand is both.",
+ {
+ "hand": _MOVE_TO_HAND_SCHEMA,
+ "visual_hand_check": _VISUAL_HAND_CHECK_SCHEMA,
+ "target": _MOVE_TARGET_SCHEMA,
+ "targets": _MOVE_TO_BOTH_TARGETS_SCHEMA,
+ "visual_hand_checks": _MOVE_TO_BOTH_VISUAL_HAND_CHECKS_SCHEMA,
+ "support_motion_phase": {
+ "type": "string",
+ "enum": ["carry_can", "transit_next_can"],
+ },
+ },
+ one_of=[
+ {
+ "properties": {"hand": {"enum": ["left", "right"]}},
+ "required": ["hand", "target"],
+ "not": {
+ "anyOf": [
+ {"required": ["targets"]},
+ {"required": ["visual_hand_checks"]},
+ ]
+ },
+ },
+ {
+ "properties": {"hand": {"const": "both"}},
+ "required": ["hand", "targets", "visual_hand_checks"],
+ "not": {
+ "anyOf": [
+ {"required": ["target"]},
+ {"required": ["visual_hand_check"]},
+ {"required": ["support_motion_phase"]},
+ ]
+ },
+ },
+ ],
+)
+
+ROTATE_WRIST_SPEC = _planner_spec(
+ "rotate_wrist",
+ "Rotate one selected BEHAVIOR wrist.",
+ {
+ "hand": _HAND_SCHEMA,
+ "visual_hand_check": _VISUAL_HAND_CHECK_SCHEMA,
+ "angle_deg": {"type": "number"},
+ "direction": {"type": "string", "enum": ["clockwise", "counterclockwise"]},
+ },
+ required=["hand", "visual_hand_check", "angle_deg"],
+)
+
+CLOSE_SPEC = _planner_spec(
+ "close",
+ "Close one selected BEHAVIOR gripper.",
+ {
+ "hand": _HAND_SCHEMA,
+ "visual_hand_check": _VISUAL_HAND_CHECK_SCHEMA,
+ },
+ required=["hand", "visual_hand_check"],
+)
+
+OPEN_SPEC = _planner_spec(
+ "open",
+ "Open one selected BEHAVIOR gripper.",
+ {
+ "hand": _HAND_SCHEMA,
+ "visual_hand_check": _VISUAL_HAND_CHECK_SCHEMA,
+ "release_visual_check": _RELEASE_VISUAL_CHECK_SCHEMA,
+ },
+ required=["hand", "visual_hand_check"],
+)
+
+PRESS_SPEC = _planner_spec(
+ "press",
+ "Press with one selected BEHAVIOR hand.",
+ {
+ "hand": _HAND_SCHEMA,
+ "visual_hand_check": _VISUAL_HAND_CHECK_SCHEMA,
+ "duration_s": {"type": "number", "exclusiveMinimum": 0.0, "maximum": 10.0},
+ },
+ required=["hand", "visual_hand_check"],
+)
+
+_NAVIGATION_VISUAL_CHECK_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "camera": {"type": "string", "const": "head"},
+ "frame_id": {"type": "string", "minLength": 1},
+ "assessment": {
+ "type": "string",
+ "const": "navigation_target_visually_confirmed",
+ },
+ },
+ "required": ["camera", "frame_id", "assessment"],
+ "additionalProperties": False,
+}
+_RELATIVE_NAVIGATION_MOTION_SCHEMA = {
+ "type": "object",
+ "properties": {
+ "kind": {"type": "string", "enum": ["translation", "rotation"]},
+ "direction": {
+ "type": "string",
+ "enum": ["forward", "backward", "left", "right"],
+ },
+ "distance_m": {"type": "number", "exclusiveMinimum": 0.0, "maximum": 1.5},
+ "angle_deg": {"type": "number", "exclusiveMinimum": 0.0, "maximum": 180.0},
+ },
+ "additionalProperties": False,
+}
+NAVIGATE_TO_SPEC = _planner_spec(
+ "navigate_to",
+ "Request a base navigation goal from a projection or explicit relative motion.",
+ {
+ "projection_id": {"type": "string", "minLength": 1},
+ "navigation_visual_check": _NAVIGATION_VISUAL_CHECK_SCHEMA,
+ "relative_motion": _RELATIVE_NAVIGATION_MOTION_SCHEMA,
+ "standoff_m": {
+ "type": "number",
+ "default": 0.85,
+ "minimum": 0.45,
+ "maximum": 1.5,
+ },
+ },
+ one_of=[
+ {
+ "required": ["projection_id", "navigation_visual_check"],
+ "not": {"required": ["relative_motion"]},
+ },
+ {
+ "required": ["relative_motion"],
+ "not": {
+ "anyOf": [
+ {"required": ["projection_id"]},
+ {"required": ["navigation_visual_check"]},
+ {"required": ["standoff_m"]},
+ ]
+ },
+ },
+ ],
+)
+
+
+def _non_bool_number(value: Any, *, field: str) -> float:
+ if isinstance(value, (bool, np.bool_)) or not isinstance(
+ value,
+ (int, float, np.integer, np.floating),
+ ):
+ raise ValueError(f"{field} must be a finite number")
+ value = float(value)
+ if not math.isfinite(value):
+ raise ValueError(f"{field} must be a finite number")
+ return value
+
+
+def validate_observe_request(
+ *,
+ camera: Any,
+ head_view: Any = None,
+ paired_hand: Any = None,
+ frame_review: Any = None,
+ depth_probe: Any = None,
+) -> dict[str, Any]:
+ if not isinstance(camera, str) or camera not in PHYSICAL_CAMERAS:
+ raise ValueError("camera must be head, left_wrist, or right_wrist")
+ if frame_review is not None and depth_probe is not None:
+ raise ValueError("frame_review and depth_probe are mutually exclusive")
+ if head_view is not None:
+ if not isinstance(head_view, str) or head_view not in HEAD_VIEW_PRESETS:
+ raise ValueError("head_view must be a supported preset")
+ if camera != "head":
+ raise ValueError("head_view is available only for camera='head'")
+ if frame_review is not None or depth_probe is not None:
+ raise ValueError("head_view cannot be combined with review/probe")
+ if paired_hand is not None:
+ if paired_hand not in {"left", "right"}:
+ raise ValueError("paired_hand must be 'left' or 'right'")
+ if camera != "head":
+ raise ValueError("paired_hand is available only for camera='head'")
+ if frame_review is not None or depth_probe is not None:
+ raise ValueError("paired_hand cannot be combined with review/probe")
+ request: dict[str, Any] = {"camera": camera}
+ if head_view is not None:
+ request["head_view"] = head_view
+ if paired_hand is not None:
+ request["paired_hand"] = paired_hand
+ if frame_review is not None:
+ request["frame_review"] = frame_review
+ if depth_probe is not None:
+ request["depth_probe"] = depth_probe
+ return request
+
+
+def _identifier(value: Any, *, name: str) -> str:
+ if not isinstance(value, str) or not value.strip():
+ raise ValueError(f"{name} must be a non-empty string")
+ return value.strip()
+
+
+def validate_relative_navigation_motion(value: Any) -> dict[str, Any]:
+ if not isinstance(value, Mapping):
+ raise ValueError("relative_motion must be an object")
+ motion = dict(value)
+ kind = motion.get("kind")
+ direction = motion.get("direction")
+ if kind == "translation":
+ expected = {"kind", "direction", "distance_m"}
+ if direction not in {"forward", "backward"}:
+ raise ValueError("translation direction must be forward or backward")
+ amount_name = "distance_m"
+ maximum = 1.5
+ elif kind == "rotation":
+ expected = {"kind", "direction", "angle_deg"}
+ if direction not in {"left", "right"}:
+ raise ValueError("rotation direction must be left or right")
+ amount_name = "angle_deg"
+ maximum = 180.0
+ else:
+ raise ValueError("relative_motion.kind must be translation or rotation")
+ if set(motion) != expected:
+ raise ValueError(f"relative_motion.{kind} requires exactly {sorted(expected)}")
+ amount = _non_bool_number(
+ motion[amount_name], field=f"relative_motion.{amount_name}"
+ )
+ if amount <= 0.0 or amount > maximum:
+ raise ValueError(f"relative_motion.{amount_name} must be within (0,{maximum}]")
+ return {"kind": str(kind), "direction": str(direction), amount_name: amount}
+
+
+def validate_visibility_recovery_check(value: Any, *, hand: Any) -> dict[str, str]:
+ if hand not in {"left", "right"}:
+ raise ValueError("hand must be 'left' or 'right'")
+ required = {"view_pair_id", "selected_hand", "assessment"}
+ if not isinstance(value, Mapping) or set(value) != required:
+ raise ValueError(
+ "visibility_recovery_check requires exactly view_pair_id, selected_hand, "
+ "and assessment"
+ )
+ if value["selected_hand"] != hand:
+ raise ValueError("hand must equal visibility_recovery_check.selected_hand")
+ if value["assessment"] != "global_target_and_matching_wrist_observed":
+ raise ValueError(
+ "visibility_recovery_check.assessment must be "
+ "global_target_and_matching_wrist_observed"
+ )
+ return {
+ "view_pair_id": _identifier(value["view_pair_id"], name="view_pair_id"),
+ "selected_hand": str(hand),
+ "assessment": "global_target_and_matching_wrist_observed",
+ }
+
+
+def validate_move_both_targets(value: Any) -> dict[str, dict[str, Any]]:
+ if not isinstance(value, Mapping) or set(value) != {"left", "right"}:
+ raise ValueError("targets requires exactly left and right")
+ normalized: dict[str, dict[str, Any]] = {}
+ for hand in ("left", "right"):
+ target = value[hand]
+ if not isinstance(target, Mapping) or set(target) != {"delta_xyz", "frame"}:
+ raise ValueError(f"targets.{hand} requires exactly delta_xyz and frame")
+ frame = target["frame"]
+ if frame not in {"world", "eef"}:
+ raise ValueError(f"targets.{hand}.frame must be world or eef")
+ delta = target["delta_xyz"]
+ delta_items = delta.tolist() if isinstance(delta, np.ndarray) else delta
+ if not isinstance(delta_items, (list, tuple)) or len(delta_items) != 3:
+ raise ValueError(f"targets.{hand}.delta_xyz must contain three numbers")
+ values = np.asarray(delta_items, dtype=np.float64)
+ if values.shape != (3,) or not np.isfinite(values).all():
+ raise ValueError(f"targets.{hand}.delta_xyz must contain finite numbers")
+ normalized[hand] = {"delta_xyz": values.tolist(), "frame": str(frame)}
+ return normalized
+
+
+def validate_move_both_visual_hand_checks(value: Any) -> dict[str, dict[str, str]]:
+ if not isinstance(value, Mapping) or set(value) != {"left", "right"}:
+ raise ValueError("visual_hand_checks requires exactly left and right")
+ normalized: dict[str, dict[str, str]] = {}
+ required = {"camera", "frame_id", "selected_hand", "assessment"}
+ for hand in ("left", "right"):
+ check = value[hand]
+ if not isinstance(check, Mapping) or set(check) != required:
+ raise ValueError(f"visual_hand_checks.{hand} has invalid keys")
+ if check["camera"] not in {"head", f"{hand}_wrist"}:
+ raise ValueError(f"visual_hand_checks.{hand}.camera is invalid")
+ if check["selected_hand"] != hand:
+ raise ValueError(f"visual_hand_checks.{hand}.selected_hand must be {hand}")
+ if check["assessment"] != "selected_hand_visually_confirmed":
+ raise ValueError(f"visual_hand_checks.{hand}.assessment is invalid")
+ normalized[hand] = {
+ "camera": str(check["camera"]),
+ "frame_id": _identifier(check["frame_id"], name="frame_id"),
+ "selected_hand": hand,
+ "assessment": "selected_hand_visually_confirmed",
+ }
+ return normalized
+
+
+def behavior_tool_specs_for_task(
+ task: str | BehaviorTaskSpec,
+) -> tuple[dict[str, Any], ...]:
+ task_spec = get_task_spec(task) if isinstance(task, str) else task
+ specs = {
+ "pi0_nav_pick": copy.deepcopy(PI0_NAV_PICK_SPEC),
+ "observe": copy.deepcopy(OBSERVE_SPEC),
+ "pixel_to_world": copy.deepcopy(PIXEL_TO_WORLD_SPEC),
+ "navigate_to": copy.deepcopy(NAVIGATE_TO_SPEC),
+ "move_to": copy.deepcopy(MOVE_TO_SPEC),
+ "rotate_wrist": copy.deepcopy(ROTATE_WRIST_SPEC),
+ "close": copy.deepcopy(CLOSE_SPEC),
+ "open": copy.deepcopy(OPEN_SPEC),
+ "press": copy.deepcopy(PRESS_SPEC),
+ }
+ if task_spec.release_visual_policy is None:
+ specs["open"]["input_schema"]["properties"].pop("release_visual_check", None)
+ return tuple(specs[name] for name in BEHAVIOR_TOOL_NAMES)
+
+
+__all__ = [
+ "ACTION_DIM",
+ "BEHAVIOR_TOOL_NAMES",
+ "CAMERA_KEYS",
+ "CLOSE_SPEC",
+ "CURRENT_PUBLIC_TOOL_CONTRACT_VERSION",
+ "DEFAULT_ACTION_CHUNK",
+ "ENV_ACTION_SEGMENTS",
+ "ENV_WIRE_SCHEMA",
+ "FRAME_REVIEW_ASSESSMENTS",
+ "HEAD_VIEW_PRESETS",
+ "MOVE_TO_SPEC",
+ "NAVIGATE_TO_SPEC",
+ "OBSERVE_SPEC",
+ "OPEN_SPEC",
+ "PI0_NAV_PICK_SPEC",
+ "PHYSICAL_CAMERAS",
+ "PIXEL_TO_WORLD_SPEC",
+ "POLICY_STATE_SEGMENTS",
+ "PRESS_SPEC",
+ "PUBLIC_PRIMITIVE_ENTRYPOINTS",
+ "PUBLIC_TOOL_CONTRACTS",
+ "ROTATE_WRIST_SPEC",
+ "VLA_WIRE_SCHEMA",
+ "behavior_tool_specs_for_task",
+ "extract_policy_state",
+ "segment_ranges",
+ "validate_action_chunk",
+ "validate_move_both_targets",
+ "validate_move_both_visual_hand_checks",
+ "validate_observe_request",
+ "validate_policy_state",
+ "validate_relative_navigation_motion",
+ "validate_visibility_recovery_check",
+]
diff --git a/robots/behavior/selfcheck.py b/robots/behavior/selfcheck.py
new file mode 100644
index 000000000..de23fc75c
--- /dev/null
+++ b/robots/behavior/selfcheck.py
@@ -0,0 +1,71 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Minimal import/runtime-contract selfcheck for the BEHAVIOR robot plugin."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from pathlib import Path
+from typing import Any
+
+
+def run_import_selfcheck() -> dict[str, Any]:
+ from robots.behavior.robot_spec import get_robot_spec
+ from robots.behavior.schemas import BEHAVIOR_TOOL_NAMES
+ from robots.behavior.task_specs import get_task_spec
+
+ if len(BEHAVIOR_TOOL_NAMES) != 9:
+ raise RuntimeError("BEHAVIOR must expose exactly 9 public primitives")
+
+ spec = get_robot_spec()
+ parser = argparse.ArgumentParser(prog="behavior-selfcheck")
+ spec.add_cli_args(parser, use_dashboard=False)
+ args = parser.parse_args(
+ [
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "1",
+ ]
+ )
+ args.output_dir = str(Path("/tmp/rpent_behavior_selfcheck"))
+ config = spec.parse_config(args)
+ return {
+ "robot": spec.name,
+ "robot_spec_fields": sorted(spec.__dataclass_fields__),
+ "task_name": config.prompt_vars["task_name"],
+ "activity_instance_id": config.prompt_vars["activity_instance_id"],
+ "behavior_mode": config.prompt_vars["behavior_mode"],
+ "memory_profile": config.prompt_vars["memory_profile"],
+ "memory_dir": config.prompt_vars["memory_dir"],
+ "behavior_episode_memory": config.prompt_vars["behavior_episode_memory"],
+ "runtime_components": [
+ item["name"] for item in spec.dashboard["runtime_components"]
+ ],
+ "tool_count_without_finish": len(BEHAVIOR_TOOL_NAMES),
+ "radio_task_language": get_task_spec("turning_on_radio").task_language,
+ }
+
+
+def main() -> None:
+ print(json.dumps(run_import_selfcheck(), indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
+
+
+__all__ = ["run_import_selfcheck"]
diff --git a/robots/behavior/sft_offline_converter.py b/robots/behavior/sft_offline_converter.py
new file mode 100644
index 000000000..f9f7dfddd
--- /dev/null
+++ b/robots/behavior/sft_offline_converter.py
@@ -0,0 +1,663 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""BEHAVIOR episode-memory library for SFT rollups and runtime catalogs.
+
+``behavior-build-memory`` consumes ``compile_runtime_catalog``; callers can use
+``write_content_addressed_rollup`` to prepare immutable expert episode rollups.
+This module has no command-line entry point and does not activate memory.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import io
+import json
+import os
+import re
+import tempfile
+from collections.abc import Mapping, Sequence
+from pathlib import Path
+from types import MappingProxyType
+from typing import Any
+
+from robots.behavior.memory.schema import (
+ canonical_json_file_bytes,
+ fail,
+ require_exact_keys,
+ require_sha256,
+ sha256_bytes,
+)
+
+SELECTION_SCHEMA_ID = "rpent_behavior_sft_expert_selection_v1"
+ROLLED_ARTIFACT_SCHEMA_ID = "rpent_behavior_sft_offline_rollup_v1"
+EXPECTED_TASK_IDS = ("task-0000", "task-0001", "task-0010", "task-0034", "task-0040")
+ACTIVE_VIEW_TASKS = {"turning_on_radio", "picking_up_trash"}
+EXPECTED_EPISODES = 10
+EXPECTED_SEGMENTS = 91
+
+
+def _sha256_file(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def load_selection(path: Path) -> Mapping[str, Any]:
+ if not path.is_file() or path.is_symlink():
+ fail(
+ "MEMORY_SFT_SELECTION_MISSING",
+ str(path),
+ "selection manifest must be a regular file",
+ )
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
+ fail("MEMORY_SFT_SELECTION_INVALID", str(path), f"{type(exc).__name__}: {exc}")
+ if not isinstance(value, Mapping):
+ fail("MEMORY_SFT_SELECTION_INVALID", str(path), "expected JSON object")
+ validate_selection(value)
+ return MappingProxyType(dict(value))
+
+
+def validate_selection(document: Mapping[str, Any]) -> None:
+ require_exact_keys(
+ document,
+ {
+ "schema_id",
+ "created_at",
+ "preliminary",
+ "activation_allowed",
+ "active",
+ "formal_compiler_admission",
+ "contract_status",
+ "source_release",
+ "coverage",
+ "evidence_boundaries",
+ "episodes",
+ },
+ path="$",
+ )
+ if (
+ document["schema_id"] != SELECTION_SCHEMA_ID
+ or document["preliminary"] is not True
+ or document["activation_allowed"] is not False
+ or document["active"] is not False
+ or document["formal_compiler_admission"] is not False
+ ):
+ fail("MEMORY_SFT_SELECTION_INVALID", "$", "non-activation identity mismatch")
+ coverage = document["coverage"]
+ if not isinstance(coverage, Mapping):
+ fail("MEMORY_SFT_SELECTION_INVALID", "coverage", "expected object")
+ expected_coverage = {
+ "selected_episode_count": EXPECTED_EPISODES,
+ "selected_segment_count": EXPECTED_SEGMENTS,
+ "catalog_episode_count": 5,
+ "query_episode_count": 5,
+ }
+ for key, expected in expected_coverage.items():
+ if coverage.get(key) != expected:
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ f"coverage.{key}",
+ f"expected {expected}",
+ )
+ episodes = document["episodes"]
+ if not isinstance(episodes, list) or len(episodes) != EXPECTED_EPISODES:
+ fail("MEMORY_SFT_SELECTION_INVALID", "episodes", "expected 10 episodes")
+ task_ids = {str(row.get("task_id")) for row in episodes if isinstance(row, Mapping)}
+ if task_ids != set(EXPECTED_TASK_IDS):
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ "episodes.task_id",
+ "expected exact five-task coverage",
+ )
+ segment_count = 0
+ for index, episode in enumerate(episodes):
+ if not isinstance(episode, Mapping):
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID", f"episodes[{index}]", "expected object"
+ )
+ for file_key in ("annotation", "metadata", "parquet"):
+ entry = episode.get(file_key)
+ if not isinstance(entry, Mapping):
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ f"episodes[{index}].{file_key}",
+ "expected object",
+ )
+ require_sha256(
+ entry.get("sha256"), path=f"episodes[{index}].{file_key}.sha256"
+ )
+ videos = episode.get("videos")
+ if not isinstance(videos, Mapping) or set(videos) != {
+ "head",
+ "left_wrist",
+ "right_wrist",
+ }:
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ f"episodes[{index}].videos",
+ "expected three camera pins",
+ )
+ for camera, entry in videos.items():
+ if not isinstance(entry, Mapping):
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ f"episodes[{index}].videos.{camera}",
+ "expected object",
+ )
+ require_sha256(
+ entry.get("sha256"), path=f"episodes[{index}].videos.{camera}.sha256"
+ )
+ segments = episode.get("segments")
+ if not isinstance(segments, list) or not segments:
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID",
+ f"episodes[{index}].segments",
+ "expected non-empty list",
+ )
+ segment_count += len(segments)
+ if segment_count != EXPECTED_SEGMENTS:
+ fail(
+ "MEMORY_SFT_SELECTION_INVALID", "segments", "expected 91 selected segments"
+ )
+
+
+def keyframes_for_episode(episode: Mapping[str, Any]) -> tuple[Mapping[str, Any], ...]:
+ frames: dict[int, dict[str, Any]] = {}
+ segments = episode["segments"]
+ for segment in segments:
+ start = int(segment["start_frame"])
+ end_exclusive = int(segment["end_frame_exclusive"])
+ end = max(start, end_exclusive - 1)
+ _add_frame(frames, start, "segment_start", segment)
+ _add_frame(frames, end, "segment_end", segment)
+ if end_exclusive - start >= 96:
+ _add_frame(
+ frames,
+ start + (end_exclusive - start) // 2,
+ "long_segment_midpoint",
+ segment,
+ )
+ first_start = min(int(segment["start_frame"]) for segment in segments)
+ last_end = max(int(segment["end_frame_exclusive"]) - 1 for segment in segments)
+ _add_frame(frames, first_start, "episode_first", segments[0])
+ _add_frame(frames, last_end, "episode_last", segments[-1])
+ return tuple(frames[index] for index in sorted(frames))
+
+
+def build_rollup(
+ selection: Mapping[str, Any], *, selection_sha256: str
+) -> Mapping[str, Any]:
+ active: list[Mapping[str, Any]] = []
+ sealed: list[Mapping[str, Any]] = []
+ for episode in selection["episodes"]:
+ row = {
+ "episode_id": episode["episode_id"],
+ "task_id": episode["task_id"],
+ "task_name": episode["task_name"],
+ "split": episode["split"],
+ "segments": episode["segments"],
+ "keyframes": list(keyframes_for_episode(episode)),
+ "source_refs": {
+ "annotation": episode["annotation"],
+ "metadata": episode["metadata"],
+ "parquet": episode["parquet"],
+ "videos": episode["videos"],
+ },
+ "usage": {
+ "source": "official_sft_offline",
+ "active_view": episode["task_name"] in ACTIVE_VIEW_TASKS,
+ "wrist_policy": "shadow_only",
+ },
+ "outcome": {
+ "success": None,
+ "authority": "official_sft_demonstration_without_runtime_success_receipt",
+ },
+ }
+ if episode["task_name"] in ACTIVE_VIEW_TASKS:
+ active.append(row)
+ else:
+ sealed.append(row)
+ if len(active) != 4 or len(sealed) != 6:
+ fail(
+ "MEMORY_SFT_ROLLUP_INVALID",
+ "active_view",
+ "expected Radio/Trash 4 active-view episodes and 6 sealed episodes",
+ )
+ return {
+ "schema_id": ROLLED_ARTIFACT_SCHEMA_ID,
+ "preliminary": True,
+ "activation_allowed": False,
+ "selection_manifest_sha256": selection_sha256,
+ "task_count": 5,
+ "episode_count": 10,
+ "segment_count": 91,
+ "keyframe_policy": "episode first/last, segment start/end, long midpoint, dedupe by frame index",
+ "active_view_policy": "turning_on_radio and picking_up_trash only",
+ "active_view": active,
+ "sealed_archive": sealed,
+ }
+
+
+def write_content_addressed_rollup(
+ *, selection_manifest: Path, output_dir: Path
+) -> Mapping[str, Any]:
+ raw = selection_manifest.read_bytes()
+ selection_sha = sha256_bytes(raw)
+ selection = load_selection(selection_manifest)
+ artifact = build_rollup(selection, selection_sha256=selection_sha)
+ payload = canonical_json_file_bytes(artifact, path="rollup")
+ digest = sha256_bytes(payload)
+ object_dir = output_dir / "objects"
+ object_path = object_dir / f"{digest}.json"
+ _write_once(object_path, payload)
+ pointer = {
+ "schema_id": "rpent_behavior_sft_offline_rollup_pointer_v1",
+ "artifact_sha256": digest,
+ "artifact_path": str(object_path),
+ "preliminary": True,
+ "activation_allowed": False,
+ }
+ _atomic_write(
+ output_dir / "latest.json", canonical_json_file_bytes(pointer, path="pointer")
+ )
+ return MappingProxyType(pointer)
+
+
+def _resolve_source_file(
+ relative_path: str, roots: Sequence[Path], *, expected_sha256: str
+) -> Path:
+ matches = [
+ root / relative_path for root in roots if (root / relative_path).is_file()
+ ]
+ if len(matches) != 1:
+ fail(
+ "MEMORY_SFT_SOURCE_RESOLUTION_INVALID",
+ relative_path,
+ f"expected one source under configured roots, found {len(matches)}",
+ )
+ path = matches[0].resolve()
+ actual = _sha256_file(path)
+ if actual != expected_sha256:
+ fail(
+ "MEMORY_SFT_SOURCE_HASH_MISMATCH",
+ relative_path,
+ f"expected {expected_sha256}, actual {actual}",
+ )
+ return path
+
+
+def _decode_video_frames(path: Path, frame_indices: Sequence[int]) -> list[Any]:
+ # imageio-ffmpeg is already part of the Behavior optional extra and avoids
+ # adding OpenCV to the source-plugin contract.
+ import imageio.v2 as imageio
+
+ try:
+ reader = imageio.get_reader(str(path), format="ffmpeg")
+ except Exception as exc:
+ fail("MEMORY_SFT_VIDEO_INVALID", str(path), f"reader open failed: {exc}")
+ decoded: list[Any] = []
+ try:
+ for frame_index in frame_indices:
+ try:
+ frame = reader.get_data(int(frame_index))
+ except Exception as exc:
+ fail(
+ "MEMORY_SFT_VIDEO_INVALID",
+ str(path),
+ f"cannot decode frame {frame_index}: {type(exc).__name__}: {exc}",
+ )
+ decoded.append(frame)
+ finally:
+ reader.close()
+ return decoded
+
+
+def _encode_in_batches(encoder: Any, images: Sequence[Any], *, batch_size: int) -> Any:
+ import numpy as np
+
+ rows: list[Any] = []
+ for offset in range(0, len(images), batch_size):
+ batch = encoder.encode_batch(list(images[offset : offset + batch_size]))
+ rows.extend(item for item in batch if item is not None)
+ if len(rows) != len(images):
+ fail("MEMORY_SFT_EMBEDDING_INVALID", "encoder", "missing embedding row")
+ return np.stack(rows, axis=0).astype(np.float32, copy=False)
+
+
+def _load_episode_rollups(rollups_dir: Path) -> Mapping[str, tuple[Path, str]]:
+ result: dict[str, tuple[Path, str]] = {}
+ pattern = re.compile(r"^Episode id: `([^`]+)`\.$", re.MULTILINE)
+ for path in sorted(rollups_dir.glob("*.memory.md")):
+ text = path.read_text(encoding="utf-8")
+ match = pattern.search(text)
+ if match:
+ result[match.group(1)] = (path, text)
+ if len(result) != EXPECTED_EPISODES:
+ fail(
+ "MEMORY_SFT_ROLLUP_INVALID",
+ str(rollups_dir),
+ "expected 10 episode memory.md rollups",
+ )
+ return MappingProxyType(result)
+
+
+def compile_runtime_catalog(
+ *,
+ selection_manifest: Path,
+ output_dir: Path,
+ video_roots: Sequence[Path],
+ rollups_dir: Path,
+ source_archive: Path,
+ weights: Path,
+ cache_dir: Path | None,
+ batch_size: int,
+) -> Mapping[str, Any]:
+ """Compile all ten official SFT episodes and a four-episode runtime view."""
+
+ if output_dir.exists():
+ fail(
+ "MEMORY_SFT_OUTPUT_COLLISION",
+ str(output_dir),
+ "output directory already exists",
+ )
+ if batch_size < 1 or batch_size > 32:
+ fail("MEMORY_SFT_BATCH_INVALID", "batch_size", "expected 1..32")
+ selection_raw = selection_manifest.read_bytes()
+ selection = load_selection(selection_manifest)
+ rollups = _load_episode_rollups(rollups_dir)
+
+ # CUDA visibility is set by main() before these imports.
+ import numpy as np
+ import torch
+ import torchvision
+
+ from robots.behavior.dino_v2.encoder import (
+ EXPECTED_SOURCE_COMMIT,
+ MODEL_ID,
+ MODEL_REVISION,
+ Dinov2DeploymentPaths,
+ Dinov2Engine,
+ Dinov2RevisionIdentity,
+ )
+ from robots.behavior.memory.index import (
+ EpisodeExperience,
+ EpisodeFrameKey,
+ write_candidate_revision,
+ )
+
+ if not torch.cuda.is_available():
+ fail(
+ "MEMORY_SFT_CUDA_UNAVAILABLE",
+ "cuda",
+ "compiler requires one visible CUDA device",
+ )
+ identity = Dinov2RevisionIdentity(
+ model_id=MODEL_ID,
+ model_revision=MODEL_REVISION,
+ source_commit=EXPECTED_SOURCE_COMMIT,
+ source_archive_sha256=_sha256_file(source_archive),
+ weights_sha256=_sha256_file(weights),
+ torch_version=str(torch.__version__),
+ torchvision_version=str(torchvision.__version__),
+ device="cuda",
+ )
+ encoder = Dinov2Engine(
+ identity,
+ Dinov2DeploymentPaths(
+ source_archive_path=source_archive.resolve(),
+ weights_path=weights.resolve(),
+ cache_dir=None if cache_dir is None else cache_dir.resolve(),
+ ),
+ )
+ active_experiences: list[Any] = []
+ active_head: list[Any] = []
+ active_left: list[Any] = []
+ active_right: list[Any] = []
+ all_inventory: list[dict[str, Any]] = []
+ all_head: list[Any] = []
+ all_left: list[Any] = []
+ all_right: list[Any] = []
+ try:
+ for episode in selection["episodes"]:
+ episode_id = str(episode["episode_id"])
+ keyframes = keyframes_for_episode(episode)
+ frame_indices = [int(item["frame_index"]) for item in keyframes]
+ encoded_channels: dict[str, Any] = {}
+ source_videos: dict[str, dict[str, Any]] = {}
+ for channel in ("head", "left_wrist", "right_wrist"):
+ video = episode["videos"][channel]
+ path = _resolve_source_file(
+ str(video["relative_path"]),
+ video_roots,
+ expected_sha256=str(video["sha256"]),
+ )
+ images = _decode_video_frames(path, frame_indices)
+ encoded_channels[channel] = _encode_in_batches(
+ encoder, images, batch_size=batch_size
+ )
+ source_videos[channel] = {
+ "relative_path": str(video["relative_path"]),
+ "sha256": str(video["sha256"]),
+ }
+ all_offset = sum(array.shape[0] for array in all_head)
+ all_head.append(encoded_channels["head"])
+ all_left.append(encoded_channels["left_wrist"])
+ all_right.append(encoded_channels["right_wrist"])
+ rollup_source, memory_markdown = rollups[episode_id]
+ active = str(episode["task_name"]) in ACTIVE_VIEW_TASKS
+ all_inventory.append(
+ {
+ "episode_id": episode_id,
+ "task_name": episode["task_name"],
+ "active_view": active,
+ "sealed": not active,
+ "frame_count": len(keyframes),
+ "all_embedding_rows": [all_offset, all_offset + len(keyframes)],
+ "memory_markdown": f"episode_rollups/{rollup_source.name}",
+ "source_videos": source_videos,
+ }
+ )
+ if not active:
+ continue
+ active_offset = sum(array.shape[0] for array in active_head)
+ frames = tuple(
+ EpisodeFrameKey(
+ frame_id=f"{episode_id}:head:{item['frame_index']}",
+ episode_id=episode_id,
+ experience_id=f"episode:{episode_id}",
+ task_name=str(episode["task_name"]),
+ frame_index=int(item["frame_index"]),
+ embedding_row=active_offset + index,
+ keyframe_kind="+".join(item["keyframe_kinds"]),
+ source_record_id="+".join(item["source_segment_ids"]),
+ frame_identity={
+ "camera": "head",
+ "keyframe_kinds": list(item["keyframe_kinds"]),
+ "source_segment_ids": list(item["source_segment_ids"]),
+ },
+ )
+ for index, item in enumerate(keyframes)
+ )
+ active_experiences.append(
+ EpisodeExperience(
+ episode_id=episode_id,
+ experience_id=f"episode:{episode_id}",
+ logical_experience_id=f"official-sft:{episode_id}",
+ task_name=str(episode["task_name"]),
+ usage={
+ "returned_scope": "whole_experience",
+ "episode_memory_markdown": memory_markdown,
+ "stage_inference": None,
+ "summary_status": "builder_generated_pending_phase6_review",
+ },
+ outcome={
+ "success": None,
+ "authority": "user_authorized_official_sft_expert_demonstration",
+ "raw_done_success": None,
+ },
+ frame_keys=frames,
+ canonical_trajectory_ref={
+ "kind": "official_sft_parquet",
+ **dict(episode["parquet"]),
+ },
+ trajectory_refs=tuple(
+ {"kind": f"official_sft_{channel}_video", **video}
+ for channel, video in source_videos.items()
+ ),
+ source={
+ "selection_manifest_sha256": sha256_bytes(selection_raw),
+ "episode_split": episode["split"],
+ "layout_fingerprint_sha256": episode[
+ "layout_fingerprint_sha256"
+ ],
+ },
+ metadata={
+ "preliminary": True,
+ "activation_allowed": False,
+ "segments": episode["segments"],
+ "wrist_policy": "shadow_only",
+ },
+ )
+ )
+ active_head.append(encoded_channels["head"])
+ active_left.append(encoded_channels["left_wrist"])
+ active_right.append(encoded_channels["right_wrist"])
+ finally:
+ encoder.close()
+
+ output_dir.mkdir(parents=True, exist_ok=False)
+ episode_rollup_output = output_dir / "episode_rollups"
+ episode_rollup_output.mkdir()
+ for _, (source_path, text) in sorted(rollups.items()):
+ _write_once(episode_rollup_output / source_path.name, text.encode("utf-8"))
+ all_embedding_bytes = io.BytesIO()
+ np.savez(
+ all_embedding_bytes,
+ head=np.concatenate(all_head, axis=0),
+ left_wrist=np.concatenate(all_left, axis=0),
+ right_wrist=np.concatenate(all_right, axis=0),
+ )
+ all_embedding_payload = all_embedding_bytes.getvalue()
+ _write_once(output_dir / "all_episode_embeddings.npz", all_embedding_payload)
+ _write_once(
+ output_dir / "all_episode_inventory.json",
+ canonical_json_file_bytes(
+ {"episodes": all_inventory}, path="all_episode_inventory"
+ ),
+ )
+ candidate = write_candidate_revision(
+ memory_dir=output_dir / "active_catalog",
+ experiences=active_experiences,
+ head_embeddings=np.concatenate(active_head, axis=0),
+ wrist_shadow_embeddings={
+ "left_wrist": np.concatenate(active_left, axis=0),
+ "right_wrist": np.concatenate(active_right, axis=0),
+ },
+ encoder_identity=identity.as_dict(),
+ activate_current=True,
+ )
+ manifest = {
+ "schema_id": "rpent_behavior_sft_episode_catalog_artifact_v1",
+ "preliminary": True,
+ "activation_allowed": False,
+ "connected_to_active_runtime": False,
+ "source_kind": "user_authorized_official_behavior_sft_training_data",
+ "selection_manifest_sha256": sha256_bytes(selection_raw),
+ "task_count": 5,
+ "episode_count": 10,
+ "segment_count": 91,
+ "active_view_episode_count": 4,
+ "sealed_episode_count": 6,
+ "keyframe_policy": "episode first/last, segment start/end, long-segment midpoint, deduplicated",
+ "active_channel": "head",
+ "wrist_policy": "shadow_only_pending_fresh_policy_query_review",
+ "stage_evidence_boundary": "SFT expert annotations are preserved as content; runtime retrieval makes no stage inference",
+ "held_out_observed": False,
+ "batch_size": batch_size,
+ "cuda_visible_device_count": int(torch.cuda.device_count()),
+ "encoder_identity": identity.as_dict(),
+ "all_episode_embeddings_sha256": sha256_bytes(all_embedding_payload),
+ "active_catalog_revision_document_sha256": candidate[
+ "revision_document_sha256"
+ ],
+ "active_catalog_path": "active_catalog",
+ "sealed_tasks": sorted(
+ {row["task_name"] for row in all_inventory if row["sealed"]}
+ ),
+ }
+ manifest_payload = canonical_json_file_bytes(manifest, path="artifact_manifest")
+ _write_once(output_dir / "manifest.json", manifest_payload)
+ return MappingProxyType(
+ {
+ "artifact_dir": str(output_dir),
+ "manifest_sha256": sha256_bytes(manifest_payload),
+ "active_catalog_revision_document_sha256": candidate[
+ "revision_document_sha256"
+ ],
+ "preliminary": True,
+ "activation_allowed": False,
+ }
+ )
+
+
+def _add_frame(
+ frames: dict[int, dict[str, Any]],
+ frame_index: int,
+ kind: str,
+ segment: Mapping[str, Any],
+) -> None:
+ frames.setdefault(
+ frame_index,
+ {
+ "frame_index": frame_index,
+ "keyframe_kinds": [],
+ "source_segment_ids": [],
+ },
+ )
+ row = frames[frame_index]
+ if kind not in row["keyframe_kinds"]:
+ row["keyframe_kinds"].append(kind)
+ segment_id = str(segment["segment_id"])
+ if segment_id not in row["source_segment_ids"]:
+ row["source_segment_ids"].append(segment_id)
+
+
+def _atomic_write(path: Path, payload: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ mode="wb", prefix=f".{path.name}.", dir=path.parent, delete=False
+ ) as handle:
+ tmp = Path(handle.name)
+ handle.write(payload)
+ handle.flush()
+ os.fsync(handle.fileno())
+ try:
+ os.replace(tmp, path)
+ finally:
+ if tmp.exists():
+ tmp.unlink()
+
+
+def _write_once(path: Path, payload: bytes) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if path.exists():
+ if path.is_file() and not path.is_symlink() and path.read_bytes() == payload:
+ return
+ fail("MEMORY_SFT_OUTPUT_COLLISION", str(path), "existing bytes differ")
+ _atomic_write(path, payload)
diff --git a/robots/behavior/task_specs.py b/robots/behavior/task_specs.py
new file mode 100644
index 000000000..1f839fa7c
--- /dev/null
+++ b/robots/behavior/task_specs.py
@@ -0,0 +1,325 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Immutable task-scoped facts for the supported BEHAVIOR tasks."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from types import MappingProxyType
+from typing import Final, Literal, Mapping
+
+BehaviorPhase = Literal["explore", "eval"]
+InstanceKind = Literal["explore", "eval", "candidate"]
+
+
+@dataclass(frozen=True)
+class ReleaseVisualPolicy:
+ """One task-specific visual authorization contract for object release."""
+
+ camera: str
+ assessment: str
+
+
+@dataclass(frozen=True)
+class BehaviorInstanceClassification:
+ """Task-scoped classification of one native activity instance."""
+
+ task_name: str
+ instance_id: int
+ kind: InstanceKind
+ public_seed: int | None
+
+
+@dataclass(frozen=True)
+class BehaviorTaskSpec:
+ """Immutable identity, mapping, and task policy for one BEHAVIOR task."""
+
+ task_index: int
+ task_name: str
+ task_language: str
+ prompt_profile_id: str
+ activity_definition_id: int
+ scene_model: str
+ public_seed_to_instance: Mapping[int, int]
+ mapping_version: str
+ candidate_mapping_version: str
+ explore_public_seeds: tuple[int, ...]
+ eval_public_seeds: tuple[int, ...]
+ release_visual_policy: ReleaseVisualPolicy | None = None
+
+ def __post_init__(self) -> None:
+ if (
+ isinstance(self.task_index, bool)
+ or not isinstance(self.task_index, int)
+ or self.task_index < 0
+ ):
+ raise ValueError("task_index must be a non-negative integer")
+ if not self.task_name or not self.task_language:
+ raise ValueError("task_name and task_language must be non-empty")
+ if self.prompt_profile_id != self.task_name:
+ raise ValueError("prompt_profile_id must exactly match task_name")
+ if (
+ isinstance(self.activity_definition_id, bool)
+ or not isinstance(self.activity_definition_id, int)
+ or self.activity_definition_id < 0
+ ):
+ raise ValueError("activity_definition_id must be non-negative")
+ if not self.scene_model:
+ raise ValueError("scene_model must be non-empty")
+
+ mapping = dict(self.public_seed_to_instance)
+ if not mapping or any(
+ isinstance(seed, bool)
+ or not isinstance(seed, int)
+ or seed < 0
+ or isinstance(instance_id, bool)
+ or not isinstance(instance_id, int)
+ or instance_id <= 0
+ for seed, instance_id in mapping.items()
+ ):
+ raise ValueError("public seed mapping must contain positive instance IDs")
+ if tuple(sorted(mapping)) != tuple(range(len(mapping))):
+ raise ValueError("public seed mapping must be contiguous from seed 0")
+ if len(set(mapping.values())) != len(mapping):
+ raise ValueError("public seed mapping must not reuse an instance")
+ object.__setattr__(self, "public_seed_to_instance", MappingProxyType(mapping))
+
+ explore = tuple(self.explore_public_seeds)
+ evaluate = tuple(self.eval_public_seeds)
+ if (
+ not explore
+ or len(set(explore)) != len(explore)
+ or len(set(evaluate)) != len(evaluate)
+ or set(explore).intersection(evaluate)
+ or set(explore).union(evaluate) != set(mapping)
+ ):
+ raise ValueError(
+ "Explore and Eval public seeds must partition the public mapping"
+ )
+ object.__setattr__(self, "explore_public_seeds", explore)
+ object.__setattr__(self, "eval_public_seeds", evaluate)
+
+ if not self.mapping_version or not self.candidate_mapping_version:
+ raise ValueError("mapping versions must be non-empty")
+ if self.release_visual_policy is not None:
+ if self.release_visual_policy.camera != "head":
+ raise ValueError("release visual policy camera must be head")
+ if not self.release_visual_policy.assessment:
+ raise ValueError("release visual assessment must be non-empty")
+
+ @property
+ def state_dir_name(self) -> str:
+ return f"{self.scene_model}_task_{self.task_name}_instances"
+
+ def tag(self, public_seed: int) -> str:
+ self.instance_for_public_seed(public_seed)
+ return f"{self.task_name}_s{public_seed}"
+
+ def instance_for_public_seed(
+ self,
+ public_seed: int,
+ *,
+ phase: BehaviorPhase | None = None,
+ ) -> int:
+ if isinstance(public_seed, bool) or not isinstance(public_seed, int):
+ raise ValueError("public_seed must be an integer")
+ try:
+ instance_id = self.public_seed_to_instance[public_seed]
+ except KeyError as error:
+ raise ValueError(
+ f"{self.task_name} has no public seed s{public_seed}"
+ ) from error
+ if phase is not None:
+ if phase == "explore":
+ allowed = self.explore_public_seeds
+ elif phase == "eval":
+ allowed = self.eval_public_seeds
+ else:
+ raise ValueError(f"unsupported BEHAVIOR phase: {phase!r}")
+ if public_seed not in allowed:
+ raise ValueError(
+ f"{self.task_name} does not allow s{public_seed} in {phase}"
+ )
+ return instance_id
+
+ def public_seed_for_instance(self, instance_id: int) -> int | None:
+ if (
+ isinstance(instance_id, bool)
+ or not isinstance(instance_id, int)
+ or instance_id <= 0
+ ):
+ raise ValueError("instance_id must be a positive integer")
+ return next(
+ (
+ seed
+ for seed, mapped_instance in self.public_seed_to_instance.items()
+ if mapped_instance == instance_id
+ ),
+ None,
+ )
+
+ def classify_instance(self, instance_id: int) -> BehaviorInstanceClassification:
+ public_seed = self.public_seed_for_instance(instance_id)
+ if public_seed in self.explore_public_seeds:
+ kind: InstanceKind = "explore"
+ elif public_seed in self.eval_public_seeds:
+ kind = "eval"
+ else:
+ kind = "candidate"
+ return BehaviorInstanceClassification(
+ task_name=self.task_name,
+ instance_id=instance_id,
+ kind=kind,
+ public_seed=public_seed,
+ )
+
+
+_TRASH_RELEASE_VISUAL_POLICY: Final = ReleaseVisualPolicy(
+ camera="head",
+ assessment="attached_object_fully_inside_receptacle_opening",
+)
+
+TURNING_ON_RADIO_TASK_SPEC: Final = BehaviorTaskSpec(
+ task_index=0,
+ task_name="turning_on_radio",
+ task_language="Turn on the radio receiver that's on the table in the living room.",
+ prompt_profile_id="turning_on_radio",
+ activity_definition_id=0,
+ scene_model="house_double_floor_lower",
+ public_seed_to_instance={
+ 0: 242,
+ 1: 109,
+ 2: 181,
+ 3: 187,
+ 4: 197,
+ 5: 203,
+ 6: 211,
+ 7: 212,
+ 8: 295,
+ 9: 298,
+ },
+ mapping_version="turning_on_radio_public_seed_v1",
+ candidate_mapping_version="turning_on_radio_candidate_instance_v1",
+ explore_public_seeds=(0,),
+ eval_public_seeds=tuple(range(1, 10)),
+)
+
+PICKING_UP_TRASH_TASK_SPEC: Final = BehaviorTaskSpec(
+ task_index=1,
+ task_name="picking_up_trash",
+ # Match the pinned RLinf behavior_task.jsonl verbatim, including its spelling.
+ task_language=(
+ "Put the three can of soda from the living room inside the tash can "
+ "in the kitchen."
+ ),
+ prompt_profile_id="picking_up_trash",
+ activity_definition_id=0,
+ scene_model="house_double_floor_lower",
+ public_seed_to_instance={
+ 0: 196,
+ 1: 67,
+ 2: 155,
+ 3: 106,
+ 4: 161,
+ 5: 245,
+ 6: 171,
+ 7: 156,
+ 8: 162,
+ 9: 246,
+ 10: 108,
+ 11: 152,
+ 12: 84,
+ 13: 198,
+ 14: 199,
+ 15: 100,
+ 16: 111,
+ 17: 151,
+ 18: 130,
+ 19: 168,
+ },
+ mapping_version="picking_up_trash_public_seed_v1",
+ candidate_mapping_version="picking_up_trash_candidate_instance_v1",
+ explore_public_seeds=tuple(range(10)),
+ eval_public_seeds=tuple(range(10, 20)),
+ release_visual_policy=_TRASH_RELEASE_VISUAL_POLICY,
+)
+
+_TASK_SPECS_BY_NAME: Final[Mapping[str, BehaviorTaskSpec]] = MappingProxyType(
+ {
+ spec.task_name: spec
+ for spec in (TURNING_ON_RADIO_TASK_SPEC, PICKING_UP_TRASH_TASK_SPEC)
+ }
+)
+_TASK_SPECS_BY_INDEX: Final[Mapping[int, BehaviorTaskSpec]] = MappingProxyType(
+ {spec.task_index: spec for spec in _TASK_SPECS_BY_NAME.values()}
+)
+
+
+def get_task_spec(task_name: str) -> BehaviorTaskSpec:
+ try:
+ return _TASK_SPECS_BY_NAME[task_name]
+ except (KeyError, TypeError) as error:
+ raise ValueError(f"unsupported BEHAVIOR task name: {task_name!r}") from error
+
+
+def get_task_spec_by_index(task_index: int) -> BehaviorTaskSpec:
+ if isinstance(task_index, bool) or not isinstance(task_index, int):
+ raise ValueError("task_index must be an integer")
+ try:
+ return _TASK_SPECS_BY_INDEX[task_index]
+ except KeyError as error:
+ raise ValueError(f"unsupported BEHAVIOR task index: {task_index!r}") from error
+
+
+def resolve_task_spec(*, task_name: str, task_index: int) -> BehaviorTaskSpec:
+ by_name = get_task_spec(task_name)
+ by_index = get_task_spec_by_index(task_index)
+ if by_name is not by_index:
+ raise ValueError(
+ f"BEHAVIOR task identity mismatch: {task_name!r} != index {task_index}"
+ )
+ return by_name
+
+
+def instance_for_public_seed(
+ task_name: str,
+ public_seed: int,
+ *,
+ phase: BehaviorPhase | None = None,
+) -> int:
+ return get_task_spec(task_name).instance_for_public_seed(public_seed, phase=phase)
+
+
+def classify_instance(
+ task_name: str,
+ instance_id: int,
+) -> BehaviorInstanceClassification:
+ return get_task_spec(task_name).classify_instance(instance_id)
+
+
+__all__ = [
+ "BehaviorInstanceClassification",
+ "BehaviorPhase",
+ "BehaviorTaskSpec",
+ "InstanceKind",
+ "PICKING_UP_TRASH_TASK_SPEC",
+ "ReleaseVisualPolicy",
+ "TURNING_ON_RADIO_TASK_SPEC",
+ "classify_instance",
+ "get_task_spec",
+ "get_task_spec_by_index",
+ "instance_for_public_seed",
+ "resolve_task_spec",
+]
diff --git a/robots/behavior/terminal_success.py b/robots/behavior/terminal_success.py
new file mode 100644
index 000000000..e96f3d6dd
--- /dev/null
+++ b/robots/behavior/terminal_success.py
@@ -0,0 +1,136 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Raw BEHAVIOR success helpers.
+
+Official task success is only the exact boolean at ``info["done"]["success"]``.
+Later snapshots, visual state, videos, and planner ``finish`` status do not
+create or revoke that bit.
+"""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import hmac
+import json
+from collections.abc import Mapping
+from typing import Any
+
+import numpy as np
+
+
+def _canonical_json_bytes(value: Any) -> bytes:
+ return json.dumps(
+ value,
+ sort_keys=True,
+ separators=(",", ":"),
+ ensure_ascii=True,
+ allow_nan=False,
+ ).encode("utf-8")
+
+
+def official_success_receipt_sha256(receipt: Mapping[str, Any]) -> str:
+ """Return the canonical digest after excluding ``receipt_sha256``."""
+
+ material = {key: value for key, value in receipt.items() if key != "receipt_sha256"}
+ return hashlib.sha256(_canonical_json_bytes(material)).hexdigest()
+
+
+def validate_official_success_receipt(value: Any) -> dict[str, Any] | None:
+ """Return a validated official-success receipt copy, or ``None``."""
+
+ if not isinstance(value, Mapping):
+ return None
+ receipt = dict(value)
+ schema_version = receipt.get("schema_version")
+ raw_done = receipt.get("raw_done")
+ env_step = receipt.get("env_step")
+ digest = receipt.get("receipt_sha256")
+ if (
+ type(schema_version) is not int
+ or schema_version != 1
+ or receipt.get("source") != 'info["done"]["success"]'
+ or not isinstance(raw_done, Mapping)
+ or raw_done.get("success") is not True
+ or isinstance(env_step, bool)
+ or not isinstance(env_step, int)
+ or env_step < 0
+ or not isinstance(digest, str)
+ ):
+ return None
+ try:
+ expected = official_success_receipt_sha256(receipt)
+ except (TypeError, ValueError):
+ return None
+ if not hmac.compare_digest(digest, expected):
+ return None
+ return copy.deepcopy(receipt)
+
+
+def official_task_success(info: Any) -> bool:
+ """Return only the raw official BEHAVIOR success bit."""
+
+ done = info.get("done") if isinstance(info, dict) else None
+ value = done.get("success") if isinstance(done, dict) else None
+ return isinstance(value, (bool, np.bool_)) and bool(value)
+
+
+def official_success_receipt_from_info(info: Any) -> dict[str, Any] | None:
+ """Extract a trusted runtime receipt if one is present and source-matched."""
+
+ runtime = info.get("_rpent") if isinstance(info, dict) else None
+ if not isinstance(runtime, dict):
+ return None
+ return validate_official_success_receipt(runtime.get("official_success_receipt"))
+
+
+def make_raw_success_receipt(
+ info: Any, *, env_step: int | None = None
+) -> dict[str, Any] | None:
+ """Return a deterministic receipt for raw success when the env did not provide one."""
+
+ if not official_task_success(info):
+ return None
+ runtime = info.get("_rpent") if isinstance(info, dict) else {}
+ if isinstance(runtime, dict):
+ step_value = runtime.get("total_env_steps", runtime.get("global_env_steps"))
+ else:
+ step_value = None
+ if type(step_value) is not int or step_value < 0:
+ if env_step is None:
+ step_value = 0
+ elif type(env_step) is int and env_step >= 0:
+ step_value = env_step
+ else:
+ return None
+ material = {
+ "schema_version": 1,
+ "source": 'info["done"]["success"]',
+ "env_step": step_value,
+ "raw_done": {"success": True},
+ }
+ return {
+ **material,
+ "receipt_sha256": official_success_receipt_sha256(material),
+ }
+
+
+__all__ = [
+ "make_raw_success_receipt",
+ "official_success_receipt_sha256",
+ "official_success_receipt_from_info",
+ "official_task_success",
+ "validate_official_success_receipt",
+]
diff --git a/robots/behavior/toolkit.py b/robots/behavior/toolkit.py
new file mode 100644
index 000000000..093627fa9
--- /dev/null
+++ b/robots/behavior/toolkit.py
@@ -0,0 +1,219 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Standard RPent Toolkit implementation for BEHAVIOR."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.schemas import behavior_tool_specs_for_task
+from robots.behavior.task_specs import get_task_spec
+from robots.behavior.tools import BehaviorPrimitives
+from rpent.dashboard.events import (
+ DashboardEventSink,
+ NullDashboardEventSink,
+ ToolResultEvent,
+)
+from rpent.memory import MemoryManager
+from rpent.session import EnvState, write_command_recipe_from_states
+from rpent.tools import common
+from rpent.tools.toolkit import Toolkit, ToolResult
+from rpent.utils.templates import substitute
+
+
+class BehaviorToolkit(Toolkit):
+ """Expose BEHAVIOR primitives through the latest standard-main contract."""
+
+ _FRAME_ARTIFACTS = {
+ "head": "head_rgb.png",
+ "left_wrist": "left_wrist_rgb.png",
+ "right_wrist": "right_wrist_rgb.png",
+ }
+
+ def __init__(
+ self,
+ *,
+ primitives_kwargs: dict[str, Any],
+ dashboard_events: DashboardEventSink | None = None,
+ memory: MemoryManager,
+ config: Any = None,
+ state_output_dir: str | Path | None = None,
+ ) -> None:
+ values = dict(primitives_kwargs)
+ if config is not None:
+ prompt_vars = dict(getattr(config, "prompt_vars", {}) or {})
+ values.setdefault("task_name", prompt_vars.get("task_name"))
+ values.setdefault("public_seed", prompt_vars.get("public_seed"))
+ behavior_phase = prompt_vars.get("behavior_phase") or prompt_vars.get(
+ "behavior_mode"
+ )
+ if behavior_phase is not None:
+ values.setdefault("behavior_phase", behavior_phase)
+ values.setdefault("max_episode_steps", prompt_vars.get("max_episode_steps"))
+ values.setdefault("output_dir", getattr(config, "output_dir", None))
+ output_dir = Path(
+ values.get("output_dir") or getattr(config, "output_dir", Path.cwd())
+ )
+ self._run_output_dir = Path(getattr(config, "output_dir", output_dir))
+ self._state_output_dir = Path(state_output_dir or output_dir)
+ values["output_dir"] = self._state_output_dir
+
+ super().__init__(
+ dashboard_events=dashboard_events or NullDashboardEventSink(),
+ state=EnvState(self._state_output_dir),
+ memory=memory,
+ )
+ self._run_state = EnvState(self._run_output_dir)
+ self._task_spec = get_task_spec(
+ str(values.get("task_name") or "turning_on_radio")
+ )
+ values["episode_video_writer"] = self._state.open_video_writer(
+ "episode.mp4",
+ step=None,
+ fps=20,
+ max_frames=2000,
+ )
+ self._primitives = BehaviorPrimitives(**values)
+ for spec in behavior_tool_specs_for_task(self._task_spec):
+ if values.get("env") is None:
+ continue
+ if spec["name"] == "pi0_nav_pick" and values.get("model") is None:
+ continue
+ self.add_tool(spec["name"], spec, getattr(self._primitives, spec["name"]))
+ finish_spec = next(
+ spec for spec in common.TOOLS_SPEC if spec["name"] == "finish"
+ )
+ self.add_tool("finish", finish_spec, self._primitives.finish)
+
+ @property
+ def primitives(self) -> BehaviorPrimitives:
+ return self._primitives
+
+ def get_tools_spec(self) -> list[dict[str, Any]]:
+ return substitute(
+ [spec for spec, _ in self._tools.values()],
+ variables={"output_dir": str(self._primitives.output_dir)},
+ )
+
+ def execute_tool(self, name: str, input_dict: dict[str, Any]) -> ToolResult:
+ result = super().execute_tool(name, input_dict)
+ if self._dashboard_result_has_frames(result.result):
+ try:
+ self._dashboard_events.emit(
+ ToolResultEvent(name=name, result=result.result)
+ )
+ except Exception:
+ pass
+ if (
+ name == "finish"
+ and isinstance(result.result, dict)
+ and result.result.get("_finish") is True
+ ):
+ saved = self._state.save("terminal_receipt.json", result.result, step=None)
+ if saved is None:
+ raise RuntimeError("failed to write terminal_receipt.json")
+ return result
+
+ @staticmethod
+ def _dashboard_result_has_frames(result: Any) -> bool:
+ if not isinstance(result, dict):
+ return False
+ for key in (
+ "_image_bytes",
+ "_depth_image_bytes",
+ "_image_left_wrist_bytes",
+ "_depth_left_wrist_bytes",
+ "_image_right_wrist_bytes",
+ "_depth_right_wrist_bytes",
+ ):
+ if result.get(key):
+ return True
+ return False
+
+ def _save_observation_images(
+ self, observation: dict[str, Any], *, step: int
+ ) -> None:
+ head = observation.get("main_images")
+ wrists = observation.get("wrist_images")
+ if head is not None:
+ image = np.asarray(head)
+ if image.ndim == 3:
+ self._state.save("head_rgb.png", image[..., :3], step=step)
+ if wrists is not None:
+ wrist_array = np.asarray(wrists)
+ if wrist_array.ndim == 4 and wrist_array.shape[0] >= 2:
+ self._state.save(
+ "left_wrist_rgb.png", wrist_array[0, ..., :3], step=step
+ )
+ self._state.save(
+ "right_wrist_rgb.png", wrist_array[1, ..., :3], step=step
+ )
+
+ def get_env_state(
+ self,
+ *,
+ command: dict[str, Any],
+ result: dict[str, Any],
+ elapsed_s: float,
+ ) -> dict[str, Any]:
+ snapshot = self._primitives.snapshot()
+ terminated = bool(snapshot.get("task_success"))
+ with self._state.record_step(
+ state=snapshot,
+ terminated=terminated,
+ truncated=False,
+ command=command,
+ result=result,
+ elapsed_s=elapsed_s,
+ ) as step:
+ observation = self._primitives.current_observation
+ if isinstance(observation, dict):
+ self._save_observation_images(observation, step=step)
+ step_idx = step
+ record = self._state.get(step_idx)
+ return {
+ **snapshot,
+ "step_idx": step_idx,
+ "artifacts": sorted(record.artifacts),
+ "command": command,
+ "result": result,
+ }
+
+ def close(self) -> None:
+ """Release clients/transports only; never synthesize task success."""
+
+ self._primitives.shutdown()
+
+ def solved(self) -> bool:
+ return self._primitives.solved()
+
+ def write_recipe(self, recipe_tag: str) -> str | None:
+ """Write an idempotent public recipe JSONL for a solved session."""
+
+ if not self.solved():
+ return None
+ if not isinstance(recipe_tag, str) or not recipe_tag.strip():
+ recipe_tag = self._task_spec.tag(self._primitives.public_seed)
+ return write_command_recipe_from_states(
+ self._state,
+ recipe_tag.strip(),
+ output_state=self._run_state,
+ )
+
+
+__all__ = ["BehaviorToolkit"]
diff --git a/robots/behavior/tools.py b/robots/behavior/tools.py
new file mode 100644
index 000000000..ef2c3b091
--- /dev/null
+++ b/robots/behavior/tools.py
@@ -0,0 +1,711 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Primitive handlers for the standard-main BEHAVIOR toolkit."""
+
+from __future__ import annotations
+
+import json
+import time
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+
+from robots.behavior.schemas import (
+ DEFAULT_ACTION_CHUNK,
+ validate_action_chunk,
+ validate_move_both_targets,
+ validate_move_both_visual_hand_checks,
+ validate_observe_request,
+ validate_relative_navigation_motion,
+)
+from robots.behavior.task_specs import get_task_spec
+from robots.behavior.terminal_success import (
+ make_raw_success_receipt,
+ official_success_receipt_from_info,
+ official_task_success,
+)
+from rpent.tools.toolkit import readonly
+from rpent.utils.logging import get_logger
+
+logger = get_logger("behavior_tools")
+
+_PRIVATE_RESULT_KEYS = {
+ "_memory_source",
+ "activity_instance_id",
+ "ground_truth",
+ "gt",
+ "hidden_state",
+ "native_instance",
+ "private_environment_metadata",
+ "simulator_state",
+ "suggested_next_action",
+ "suggested_next_tool",
+}
+_PUBLIC_IMAGE_BYTE_FIELDS = {
+ "_image_bytes",
+ "_depth_image_bytes",
+ "_image_left_wrist_bytes",
+ "_depth_left_wrist_bytes",
+ "_image_right_wrist_bytes",
+ "_depth_right_wrist_bytes",
+}
+
+
+def _jsonable(value: Any) -> Any:
+ try:
+ import torch
+
+ if torch.is_tensor(value):
+ value = value.detach().cpu().numpy()
+ except Exception:
+ pass
+ if isinstance(value, np.ndarray):
+ return value.tolist()
+ if isinstance(value, np.generic):
+ return value.item()
+ if isinstance(value, dict):
+ return {str(key): _jsonable(item) for key, item in value.items()}
+ if isinstance(value, (list, tuple)):
+ return [_jsonable(item) for item in value]
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ return value
+ return repr(value)
+
+
+def _sanitize_public_result(value: Any) -> Any:
+ """Remove privileged diagnostics while preserving public image byte fields."""
+
+ if isinstance(value, dict):
+ public: dict[str, Any] = {}
+ for key, item in value.items():
+ lowered = str(key).lower()
+ if lowered == "info":
+ public[str(key)] = _public_info_summary(item)
+ continue
+ if (
+ lowered in _PRIVATE_RESULT_KEYS
+ or lowered.startswith(("ground_truth_", "gt_", "private_"))
+ or lowered.endswith("_ground_truth")
+ ):
+ continue
+ if lowered in _PUBLIC_IMAGE_BYTE_FIELDS:
+ public[str(key)] = None if item is None else bytes(item)
+ continue
+ public[str(key)] = _sanitize_public_result(item)
+ return public
+ if isinstance(value, (list, tuple)):
+ return [_sanitize_public_result(item) for item in value]
+ return _jsonable(value)
+
+
+def _public_info_summary(info: Any) -> dict[str, Any]:
+ if not isinstance(info, dict):
+ return {}
+ public: dict[str, Any] = {}
+ if isinstance(info.get("done"), dict):
+ public["done"] = _jsonable(info["done"])
+ for field in ("executed_steps", "stop_reason", "success_step_in_chunk"):
+ if field in info:
+ public[field] = _jsonable(info[field])
+ runtime = info.get("_rpent")
+ if isinstance(runtime, dict):
+ allowed = {
+ "total_env_steps",
+ "global_env_steps",
+ "attempt_index",
+ "attempt_nonce",
+ "run_nonce",
+ "official_success_receipt",
+ }
+ public["_rpent"] = {
+ key: _jsonable(runtime[key]) for key in allowed if key in runtime
+ }
+ return public
+
+
+def _info_from_result(value: Any) -> dict[str, Any] | None:
+ if (
+ isinstance(value, (tuple, list))
+ and len(value) == 5
+ and isinstance(value[4], dict)
+ ):
+ return value[4]
+ if isinstance(value, dict):
+ info = value.get("info")
+ if isinstance(info, dict):
+ return info
+ if isinstance(value.get("done"), dict):
+ return value
+ return None
+
+
+def _terminal_capture_pointer_from_info(info: Any) -> dict[str, Any] | None:
+ """Reduce a terminal capture to public linkage fields only."""
+
+ runtime = info.get("_rpent") if isinstance(info, dict) else None
+ if not isinstance(runtime, dict):
+ return None
+ capture = runtime.get("terminal_capture")
+ if not isinstance(capture, dict):
+ capture = runtime.get("vla_after_capture")
+ if not isinstance(capture, dict):
+ return None
+ group_id = capture.get("capture_group_id")
+ step = capture.get(
+ "capture_env_step", capture.get("env_step", capture.get("simulator_step"))
+ )
+ frame_ids = capture.get("frame_ids")
+ if (
+ not isinstance(group_id, str)
+ or not group_id
+ or isinstance(step, bool)
+ or not isinstance(step, (int, np.integer))
+ or not isinstance(frame_ids, dict)
+ ):
+ return None
+ cameras = ("head", "left_wrist", "right_wrist")
+ if any(
+ not isinstance(frame_ids.get(camera), str) or not frame_ids[camera]
+ for camera in cameras
+ ):
+ return None
+ return {
+ "capture_group_id": group_id,
+ "capture_env_step": int(step),
+ "simulator_step": int(step),
+ "frame_ids": {camera: frame_ids[camera] for camera in cameras},
+ }
+
+
+def _observation_summary(observation: Any) -> dict[str, Any]:
+ if not isinstance(observation, dict):
+ return {}
+ summary: dict[str, Any] = {}
+ for key, value in observation.items():
+ if key in {"main_images", "wrist_images", "states"}:
+ array = np.asarray(value)
+ summary[key] = {
+ "shape": list(array.shape),
+ "dtype": str(array.dtype),
+ }
+ elif key == "task_descriptions":
+ summary[key] = _jsonable(value)
+ elif isinstance(value, (str, int, float, bool)) or value is None:
+ summary[str(key)] = value
+ return summary
+
+
+class BehaviorPrimitives:
+ """Handlers registered by :class:`robots.behavior.toolkit.BehaviorToolkit`."""
+
+ def __init__(
+ self,
+ *,
+ env: Any = None,
+ model: Any = None,
+ max_episode_steps: int | None = None,
+ output_dir: str | Path | None = None,
+ episode_video_writer: Any = None,
+ action_horizon: int = DEFAULT_ACTION_CHUNK,
+ initial_observation: dict[str, Any] | None = None,
+ initial_info: Any = None,
+ behavior_phase: str = "eval",
+ task_name: str = "turning_on_radio",
+ public_seed: int = 0,
+ episode_memory_index: Any = None,
+ dino_component: Any = None,
+ ) -> None:
+ self.env = env
+ self.model = model
+ self.max_episode_steps = (
+ None if max_episode_steps is None else int(max_episode_steps)
+ )
+ self.output_dir = Path(output_dir) if output_dir else Path.cwd()
+ self._episode_video_writer = episode_video_writer
+ self._recording = episode_video_writer is not None
+ self.action_horizon = int(action_horizon)
+ self._current_observation = initial_observation
+ self._current_info = initial_info if isinstance(initial_info, dict) else {}
+ self.behavior_phase = str(behavior_phase)
+ if self.behavior_phase not in {"eval", "explore"}:
+ raise ValueError("behavior_phase must be 'eval' or 'explore'")
+ self.task_spec = get_task_spec(str(task_name))
+ self.task_name = self.task_spec.task_name
+ self.public_seed = int(public_seed)
+ self.task_spec.instance_for_public_seed(self.public_seed, phase=None)
+ self.episode_memory_index = episode_memory_index
+ self.dino_component = dino_component
+ self._episode_memory_decision = self._retrieve_episode_memory(
+ self._current_observation
+ )
+ self.started_monotonic = time.monotonic()
+ self.last_result: dict[str, Any] | None = None
+ self._local_env_steps = 0
+ self._official_success_latched = official_task_success(self._current_info)
+ self._official_success_receipt = official_success_receipt_from_info(
+ self._current_info
+ ) or make_raw_success_receipt(self._current_info, env_step=self.total_env_steps)
+ if self._recording:
+ self.record_frame(self._current_observation)
+
+ @property
+ def elapsed_wall_clock_s(self) -> float:
+ return max(0.0, time.monotonic() - self.started_monotonic)
+
+ @property
+ def total_env_steps(self) -> int:
+ reported = getattr(self.env, "total_env_steps", None)
+ if isinstance(reported, (int, np.integer)) and not isinstance(
+ reported, (bool, np.bool_)
+ ):
+ return max(self._local_env_steps, int(reported))
+ return self._local_env_steps
+
+ @property
+ def current_observation(self) -> dict[str, Any] | None:
+ return self._current_observation
+
+ def solved(self) -> bool:
+ env_solved = bool(getattr(self.env, "official_success_latched", False))
+ return bool(self._official_success_latched or env_solved)
+
+ def official_success_receipt(self) -> dict[str, Any] | None:
+ env_receipt = getattr(self.env, "official_success_receipt", None)
+ if isinstance(env_receipt, dict):
+ return _jsonable(env_receipt)
+ return (
+ _jsonable(self._official_success_receipt)
+ if self._official_success_receipt
+ else None
+ )
+
+ def _remaining_steps(self) -> int | None:
+ if self.max_episode_steps is None:
+ return None
+ return max(0, int(self.max_episode_steps) - self.total_env_steps)
+
+ def _require_env(self) -> Any:
+ if self.env is None:
+ raise RuntimeError("BEHAVIOR env component is unavailable")
+ return self.env
+
+ def _require_model(self) -> Any:
+ if self.model is None:
+ raise RuntimeError("BEHAVIOR VLA component is unavailable")
+ return self.model
+
+ def _note_info(self, info: Any) -> None:
+ if not isinstance(info, dict):
+ return
+ self._current_info = info
+ runtime = info.get("_rpent")
+ if isinstance(runtime, dict):
+ steps = runtime.get("total_env_steps", runtime.get("global_env_steps"))
+ if isinstance(steps, (int, np.integer)) and not isinstance(
+ steps, (bool, np.bool_)
+ ):
+ self._local_env_steps = max(self._local_env_steps, int(steps))
+ if official_task_success(info):
+ self._official_success_latched = True
+ self._official_success_receipt = official_success_receipt_from_info(
+ info
+ ) or make_raw_success_receipt(info, env_step=self.total_env_steps)
+
+ def recorded_frame_count(self) -> int:
+ writer = self._episode_video_writer
+ return int(getattr(writer, "frames_written", 0) or 0)
+
+ def record_frame(self, observation: Any) -> None:
+ if not self._recording or self._episode_video_writer is None:
+ return
+ if not isinstance(observation, dict):
+ return
+ image = self._rgb8(observation.get("main_images"))
+ if image is None:
+ return
+ try:
+ appended = bool(self._episode_video_writer.append(image))
+ except Exception as exc:
+ self._recording = False
+ logger.warning("failed to append BEHAVIOR episode frame: %s", exc)
+ return
+ if not appended:
+ self._recording = False
+ logger.warning("stopped BEHAVIOR episode recording after frame limit")
+
+ def stop_recording(self) -> str | None:
+ writer = self._episode_video_writer
+ self._recording = False
+ if writer is None:
+ return None
+ try:
+ return writer.close()
+ except Exception as exc:
+ logger.warning("failed to save BEHAVIOR episode video: %s", exc)
+ return None
+
+ @staticmethod
+ def _rgb8(value: Any, *, first: int | None = None) -> np.ndarray | None:
+ if value is None:
+ return None
+ image = np.asarray(value)
+ if first is not None:
+ if image.ndim != 4 or image.shape[0] <= first:
+ return None
+ image = image[first]
+ elif image.ndim == 4 and image.shape[0] == 1:
+ image = image[0]
+ if image.ndim != 3 or image.shape[2] < 3:
+ return None
+ image = image[..., :3]
+ if image.dtype != np.uint8:
+ if (
+ np.issubdtype(image.dtype, np.floating)
+ and image.size
+ and float(np.nanmax(image)) <= 1.0
+ ):
+ image = np.rint(np.clip(image, 0.0, 1.0) * 255.0)
+ image = np.clip(image, 0, 255).astype(np.uint8)
+ return np.ascontiguousarray(image)
+
+ def _retrieve_episode_memory(self, observation: Any) -> dict[str, Any] | None:
+ if (
+ self.episode_memory_index is None
+ or self.dino_component is None
+ or not isinstance(observation, dict)
+ ):
+ return None
+ head = self._rgb8(observation.get("main_images"))
+ if head is None:
+ return None
+ wrists = observation.get("wrist_images")
+ left = self._rgb8(wrists, first=0)
+ right = self._rgb8(wrists, first=1)
+ encoded = self.dino_component.encode_batch([head, left, right])
+ head_embedding = encoded[0]
+ if head_embedding is None:
+ raise RuntimeError(
+ "DINO returned no head embedding for episode-memory retrieval"
+ )
+ shadow = {
+ channel: vector
+ for channel, vector in zip(("left_wrist", "right_wrist"), encoded[1:])
+ if vector is not None
+ }
+ decision = self.episode_memory_index.retrieve(
+ task_name=self.task_name,
+ head_embedding=head_embedding,
+ wrist_shadow_embeddings=shadow,
+ )
+ return _jsonable(dict(decision))
+
+ def _envelope(
+ self,
+ name: str,
+ payload: Any,
+ *,
+ primitive_success: bool | None = None,
+ stop_reason: str | None = None,
+ ) -> dict[str, Any]:
+ info = _info_from_result(payload)
+ self._note_info(info)
+ if isinstance(payload, dict) and "_observation" in payload:
+ payload = dict(payload)
+ observation = payload.pop("_observation")
+ if isinstance(observation, dict):
+ self._current_observation = observation
+ # Backend primitives return their final physical observation;
+ # do not issue a new RPC after official success just for video.
+ self.record_frame(observation)
+ if self.solved() or payload.get("stop_reason") in {
+ "terminated",
+ "truncated",
+ }:
+ self.stop_recording()
+ public_payload = _sanitize_public_result(payload)
+ result: dict[str, Any] = {
+ "name": name,
+ "primitive_success": (
+ bool(primitive_success)
+ if primitive_success is not None
+ else not (
+ isinstance(public_payload, dict) and public_payload.get("error")
+ )
+ ),
+ "task_success": self.solved(),
+ "official_success_source": 'info["done"]["success"]',
+ "total_env_steps": self.total_env_steps,
+ "max_episode_steps": self.max_episode_steps,
+ }
+ if stop_reason is not None:
+ result["stop_reason"] = stop_reason
+ if isinstance(public_payload, dict):
+ result.update(public_payload)
+ else:
+ result["value"] = public_payload
+ if self._episode_memory_decision is not None:
+ result["episode_memory"] = self._episode_memory_decision
+ if self.solved():
+ result["official_success_receipt"] = self.official_success_receipt()
+ terminal_capture = _terminal_capture_pointer_from_info(self._current_info)
+ if terminal_capture is not None:
+ result["terminal_capture"] = terminal_capture
+ self.last_result = result
+ return result
+
+ def snapshot(self) -> dict[str, Any]:
+ return {
+ "task_name": self.task_name,
+ "public_seed": self.public_seed,
+ "behavior_phase": self.behavior_phase,
+ "task_success": self.solved(),
+ "official_success_source": 'info["done"]["success"]',
+ "official_success_receipt": self.official_success_receipt(),
+ "total_env_steps": self.total_env_steps,
+ "max_episode_steps": self.max_episode_steps,
+ "elapsed_wall_clock_s": round(self.elapsed_wall_clock_s, 3),
+ "observation": _observation_summary(self._current_observation),
+ "episode_memory": self._episode_memory_decision,
+ }
+
+ def pi0_nav_pick(self, *, instruction: str, chunks: int) -> dict[str, Any]:
+ if not isinstance(instruction, str) or not instruction.strip():
+ raise ValueError("instruction must be a non-empty string")
+ if isinstance(chunks, bool) or not isinstance(chunks, int) or chunks <= 0:
+ raise ValueError("chunks must be a positive integer")
+ env = self._require_env()
+ model = self._require_model()
+ if self.solved():
+ return self._envelope(
+ "pi0_nav_pick",
+ {},
+ primitive_success=True,
+ stop_reason="already_officially_successful",
+ )
+
+ started_steps = self.total_env_steps
+ chunks_used = 0
+ full_chunks = 0
+ stop_reason = "exact_requested_chunks"
+ last_info: dict[str, Any] | None = self._current_info
+ started = time.monotonic()
+
+ for _ in range(chunks):
+ remaining = self._remaining_steps()
+ if remaining is not None and remaining <= 0:
+ stop_reason = "episode_step_budget_exhausted"
+ break
+ if self._current_observation is None:
+ self._current_observation, last_info = env.current_observation()
+ self._note_info(last_info)
+ if self.solved():
+ stop_reason = "official_task_success"
+ break
+ env_obs = dict(self._current_observation)
+ env_obs["task_descriptions"] = instruction.strip()
+ actions = model.predict(env_obs, options={"mode": "eval"})
+ action_array = validate_action_chunk(actions)
+ if remaining is not None:
+ action_array = action_array[:remaining]
+ if action_array.shape[0] <= 0:
+ stop_reason = "episode_step_budget_exhausted"
+ break
+ ret = env.chunk_step(action_array, return_all_frames=self._recording)
+ chunks_used += 1
+ obs, _reward, terminated, truncated, info = ret
+ if isinstance(obs, list):
+ for frame_obs in obs:
+ self.record_frame(frame_obs)
+ if obs and isinstance(obs[-1], dict):
+ self._current_observation = obs[-1]
+ elif isinstance(obs, dict):
+ self._current_observation = obs
+ self.record_frame(obs)
+ last_info = info if isinstance(info, dict) else {}
+ self._note_info(last_info)
+ executed_steps = None
+ if isinstance(last_info, dict):
+ value = last_info.get("executed_steps")
+ if isinstance(value, (int, np.integer)) and not isinstance(
+ value, (bool, np.bool_)
+ ):
+ executed_steps = int(value)
+ if executed_steps is None:
+ executed_steps = int(action_array.shape[0])
+ self._local_env_steps = max(
+ self._local_env_steps,
+ started_steps + executed_steps,
+ )
+ if executed_steps >= int(action_array.shape[0]):
+ full_chunks += 1
+ if self.solved():
+ stop_reason = "official_task_success"
+ self.stop_recording()
+ break
+ if bool(terminated):
+ stop_reason = "terminated"
+ self.stop_recording()
+ break
+ if bool(truncated):
+ stop_reason = "truncated"
+ self.stop_recording()
+ break
+
+ env_steps_used = max(0, self.total_env_steps - started_steps)
+ result = {
+ "terminated": stop_reason == "terminated",
+ "truncated": stop_reason == "truncated",
+ "stop_reason": stop_reason,
+ "requested_chunks": int(chunks),
+ "chunks_used": chunks_used,
+ "full_chunks_executed": full_chunks,
+ "exact_requested_chunks_completed": chunks_used == int(chunks)
+ and stop_reason == "exact_requested_chunks",
+ "env_steps_used": env_steps_used,
+ "total_env_steps": self.total_env_steps,
+ "max_episode_steps": self.max_episode_steps,
+ "action_horizon": self.action_horizon,
+ "required_action_shape": [None, 23],
+ "elapsed_s": round(time.monotonic() - started, 3),
+ "info": last_info or {},
+ }
+ return self._envelope(
+ "pi0_nav_pick",
+ result,
+ primitive_success=bool(chunks_used > 0 or self.solved()),
+ stop_reason=stop_reason,
+ )
+
+ @readonly
+ def observe(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ request = validate_observe_request(**kwargs)
+ result = env.observe(**request)
+ info = _info_from_result(result)
+ if info is not None:
+ self._note_info(info)
+ return self._envelope("observe", result, primitive_success=True)
+
+ @readonly
+ def pixel_to_world(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ result = env.pixel_to_world(**kwargs)
+ return self._envelope("pixel_to_world", result, primitive_success=True)
+
+ def navigate_to(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ if "relative_motion" in kwargs and kwargs["relative_motion"] is not None:
+ kwargs = {
+ **kwargs,
+ "relative_motion": validate_relative_navigation_motion(
+ kwargs["relative_motion"]
+ ),
+ }
+ return self._envelope("navigate_to", env.navigate_to(**kwargs))
+
+ def move_to(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ hand = kwargs.get("hand")
+ if hand == "both":
+ kwargs = {
+ **kwargs,
+ "targets": validate_move_both_targets(kwargs.get("targets")),
+ "visual_hand_checks": validate_move_both_visual_hand_checks(
+ kwargs.get("visual_hand_checks")
+ ),
+ }
+ elif hand not in {"left", "right"}:
+ raise ValueError("hand must be 'left', 'right', or 'both'")
+ return self._envelope("move_to", env.move_to(**kwargs))
+
+ def rotate_wrist(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ return self._envelope("rotate_wrist", env.rotate_wrist(**kwargs))
+
+ def close(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ return self._envelope("close", env.close_gripper(**kwargs))
+
+ def open(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ return self._envelope("open", env.open_gripper(**kwargs))
+
+ def press(self, **kwargs: Any) -> dict[str, Any]:
+ env = self._require_env()
+ return self._envelope("press", env.press(**kwargs))
+
+ @readonly
+ def finish(self, *, status: str, summary: str) -> dict[str, Any]:
+ if not isinstance(status, str) or not status.strip():
+ raise ValueError("status must be a non-empty string")
+ if not isinstance(summary, str) or not summary.strip():
+ raise ValueError("summary must be a non-empty string")
+ episode_video_artifact = self.stop_recording()
+ receipt = {
+ "schema_version": 1,
+ "kind": "behavior_finish_terminal_receipt",
+ "planner_status": status.strip(),
+ "summary": summary.strip(),
+ "task_success": self.solved(),
+ "official_success_source": 'info["done"]["success"]',
+ "official_success_receipt": self.official_success_receipt(),
+ "total_env_steps": self.total_env_steps,
+ "max_episode_steps": self.max_episode_steps,
+ }
+ if episode_video_artifact:
+ receipt["episode_video_artifact"] = episode_video_artifact
+ receipt["episode_video_frames"] = self.recorded_frame_count()
+ result = {"_finish": True, **receipt}
+ self.last_result = result
+ return result
+
+ def shutdown(self) -> None:
+ # VLA and DINO belong to the Dashboard Session shared runtime. A
+ # TaskRun only releases its task-scoped ENV transport; shared daemons
+ # and clients are released by the runtime owner after the session.
+ self.stop_recording()
+ for candidate in (self.env,):
+ if candidate is None:
+ continue
+ closer = getattr(candidate, "close_transport", None)
+ if not callable(closer):
+ closer = getattr(candidate, "close", None)
+ if callable(closer):
+ try:
+ closer()
+ except Exception:
+ pass
+
+ def recipe_records(self) -> list[dict[str, Any]]:
+ records: list[dict[str, Any]] = []
+ if self.last_result is None:
+ return records
+ records.append(
+ {
+ "task_name": self.task_name,
+ "public_seed": self.public_seed,
+ "last_result": _sanitize_public_result(self.last_result),
+ }
+ )
+ return json.loads(json.dumps(records, default=str))
+
+
+__all__ = [
+ "BehaviorPrimitives",
+ "_sanitize_public_result",
+ "_terminal_capture_pointer_from_info",
+ "official_task_success",
+]
diff --git a/rpent/cli/dashboard.py b/rpent/cli/dashboard.py
index a90c44fcc..aaf6863a4 100644
--- a/rpent/cli/dashboard.py
+++ b/rpent/cli/dashboard.py
@@ -179,6 +179,17 @@ def _run_dashboard_task(
started = time.time()
solved = False
memory_manager = None
+ # BEHAVIOR TaskRuns use behavior_mode; LIBERO uses the shared explore flag.
+ # A BEHAVIOR TaskRun already owns its fresh ENV (one episode per TaskRun).
+ toolkit_mode = (
+ "exploration"
+ if (
+ getattr(task_args, "behavior_mode", "eval") == "explore"
+ if args.robot_name == "behavior"
+ else getattr(task_args, "explore", False)
+ )
+ else "evaluation"
+ )
try:
task_daemons, task_primitives_kwargs = robot_spec.init_runtime(
task_args,
@@ -231,13 +242,13 @@ def _run_dashboard_task(
state.begin_planner_session(
video_path=state_output_dir / "episode.mp4",
)
- if args.robot_name == "libero":
+ if args.robot_name in ("libero", "behavior"):
toolkit = get_toolkit(
args.robot_name,
primitives_kwargs=primitives_kwargs,
dashboard_events=state,
config=run_config,
- mode="exploration" if task_args.explore else "evaluation",
+ mode=toolkit_mode,
attempts_per_session=getattr(
task_args, "explore_attempts_per_session", 0
),
@@ -277,7 +288,7 @@ def _run_dashboard_task(
messages += result.messages
stats = result.stats
agent_error = result.error
- if args.robot_name == "libero":
+ if args.robot_name in ("libero", "behavior"):
solved = toolkit.solved()
if solved:
recipe_path = toolkit.write_recipe(recipe_tag)
@@ -334,7 +345,7 @@ def _run_dashboard_task(
init_output_dir(session_root, verbose=args.verbose)
if (
- getattr(task_args, "explore", False)
+ toolkit_mode == "exploration"
and getattr(task_args, "auto_merge_memory", False)
and not agent_error
and not state.task_replacement_requested
@@ -351,6 +362,6 @@ def _run_dashboard_task(
except Exception as exc:
warning = f"memory finalization failed: {type(exc).__name__}: {exc}"
logger.warning("%s", warning)
- state.report_task_warning(f"Task succeeded, but {warning}")
+ state.report_task_warning(f"Memory finalization warning: {warning}")
return agent_error
diff --git a/rpent/cli/main.py b/rpent/cli/main.py
index e2faf5cf7..5c7f8070f 100644
--- a/rpent/cli/main.py
+++ b/rpent/cli/main.py
@@ -320,10 +320,11 @@ def main() -> int:
)
args = parser.parse_args()
args.robot_name = early.robot_name
+ on_explore_session = getattr(robot_spec, "on_explore_session", None)
if args.dashboard and args.interactive:
parser.error("--dashboard and --interactive cannot be used together")
- if args.explore and args.robot_name != "libero":
- parser.error("--explore is currently supported only for LIBERO")
+ if args.explore and args.robot_name != "libero" and on_explore_session is None:
+ parser.error(f"--explore is not supported for {args.robot_name}")
if args.explore and args.memory_profile == "hf":
parser.error("--explore cannot be used with --memory-profile hf")
if args.explore and getattr(args, "explore_sessions", 1) <= 0:
@@ -331,12 +332,21 @@ def main() -> int:
args.memory_profile = args.memory_profile or ("local" if args.explore else "hf")
if args.memory_profile == "hf" and args.memory_dir is not None:
parser.error("--memory-dir requires --memory-profile local or --explore")
+ run_config = None
+ if args.explore and on_explore_session is not None:
+ # Let session-owning plugins validate their constraints before startup,
+ # including requests routed to the Dashboard launcher.
+ try:
+ run_config = robot_spec.parse_config(args)
+ except ValueError as exc:
+ parser.error(str(exc))
if args.dashboard:
from rpent.cli.dashboard import run_dashboard_session
return run_dashboard_session(args, robot_spec, parser=parser)
- run_config = robot_spec.parse_config(args)
+ if run_config is None:
+ run_config = robot_spec.parse_config(args)
recipe_tag = run_config.recipe_tag
output_dir = run_config.output_dir
prompt_vars = run_config.prompt_vars
@@ -449,7 +459,13 @@ def main() -> int:
state_output_dir = (
output_dir / "sessions" / f"session_{session_number:03d}"
)
- if robot_name == "libero":
+ if args.explore and on_explore_session is not None:
+ primitives_kwargs.update(
+ on_explore_session(
+ args, state_output_dir, dashboard_events, daemons
+ )
+ )
+ if robot_name == "libero" or on_explore_session is not None:
toolkit = get_toolkit(
robot_name,
primitives_kwargs=primitives_kwargs,
@@ -481,7 +497,7 @@ def main() -> int:
messages += result.messages
stats = result.stats
agent_error = result.error
- if robot_name == "libero":
+ if robot_name == "libero" or on_explore_session is not None:
solved = toolkit.solved()
if solved:
recipe_path = toolkit.write_recipe(recipe_tag)
diff --git a/rpent/dashboard/state.py b/rpent/dashboard/state.py
index 3904ccd44..90baf243e 100644
--- a/rpent/dashboard/state.py
+++ b/rpent/dashboard/state.py
@@ -54,6 +54,7 @@
TaskRequest = dict[str, Any]
_INTEGER = re.compile(r"-?[0-9]+")
_UNSAFE_SLUG = re.compile(r"[^A-Za-z0-9_.-]+")
+_OFFICIAL_SUCCESS_SOURCE = 'info["done"]["success"]'
def _to_json_safe(value: Any) -> Any:
@@ -71,6 +72,46 @@ def _to_json_safe(value: Any) -> Any:
return value
+def _channel_result_keys(channel: dict[str, Any]) -> tuple[str, ...]:
+ explicit = channel.get("result_key", channel.get("result_bytes_key"))
+ if isinstance(explicit, str) and explicit:
+ return (explicit,)
+ if isinstance(explicit, (list, tuple)):
+ return tuple(str(key) for key in explicit if key)
+ name = str(channel.get("name", ""))
+ if name == "camera":
+ return ("_image_cam_bytes", "_image_bytes")
+ if name == "wrist":
+ return ("_image_wrist_bytes",)
+ if name == "head":
+ return ("_image_bytes",)
+ if name == "left_wrist":
+ return ("_image_left_wrist_bytes", "_image_cam_bytes")
+ if name == "right_wrist":
+ return ("_image_right_wrist_bytes", "_image_wrist_bytes")
+ return ()
+
+
+def _result_official_success(result: dict[str, Any], *, terminated: bool) -> bool:
+ """Return whether a tool result contains robot-official success evidence."""
+
+ if (
+ result.get("official_success_source") == _OFFICIAL_SUCCESS_SOURCE
+ or "official_success_receipt" in result
+ ):
+ receipt = result.get("official_success_receipt")
+ if not isinstance(receipt, dict):
+ return False
+ raw_done = receipt.get("raw_done")
+ return (
+ result.get("task_success") is True
+ and receipt.get("source") == _OFFICIAL_SUCCESS_SOURCE
+ and isinstance(raw_done, dict)
+ and raw_done.get("success") is True
+ )
+ return bool(terminated)
+
+
def _parse_task(task_spec: dict[str, Any], text: str) -> TaskRequest | None:
tokens = text.split()
command = task_spec["command"]
@@ -147,6 +188,7 @@ def __init__(
self._task_state: str | None = None
self._terminated = False
self._truncated = False
+ self._official_success = False
self._error: str | None = None
self._usage = {"in": 0, "out": 0, "tool_calls": 0}
self._planner_usage_base = {"in": 0, "out": 0, "tool_calls": 0}
@@ -157,6 +199,10 @@ def __init__(
self._events: list[dict[str, Any]] = []
self._timeline: list[dict[str, Any]] = []
self._frames: dict[str, bytes] = {}
+ self._frame_result_keys = {
+ channel["name"]: _channel_result_keys(channel)
+ for channel in self._frame_channels
+ }
self._frame_idx = -1
self.env_state: EnvState | None = None
self.frame_artifacts: dict[str, str] = {}
@@ -323,6 +369,9 @@ def complete_task(
self._task_state = state
self._terminated = any(item.get("terminated") for item in self._timeline)
self._truncated = any(item.get("truncated") for item in self._timeline)
+ self._official_success = any(
+ item.get("official_success") for item in self._timeline
+ )
self._error = None if error is None else str(error)
self._task_replacement_requested = False
self._seal_interaction_locked()
@@ -349,6 +398,7 @@ def _begin_task_locked(
self._task_replacement_requested = False
self._terminated = False
self._truncated = False
+ self._official_success = False
self._error = None
self._usage = {"in": 0, "out": 0, "tool_calls": 0}
self._planner_usage_base = {"in": 0, "out": 0, "tool_calls": 0}
@@ -655,10 +705,12 @@ def _apply_tool_result(self, event: ToolResultEvent) -> None:
result = event.result
if not isinstance(result, dict):
return
- frames = {
- "camera": result.get("_image_cam_bytes") or result.get("_image_bytes"),
- "wrist": result.get("_image_wrist_bytes"),
- }
+ frames: dict[str, Any] = {}
+ for kind, keys in self._frame_result_keys.items():
+ for key in keys:
+ if result.get(key):
+ frames[kind] = result[key]
+ break
self._update_frames(
step=result.get("step"),
frames={kind: data for kind, data in frames.items() if data},
@@ -676,6 +728,7 @@ def _apply_tool_result(self, event: ToolResultEvent) -> None:
action = str(command.get("action", name))
terminated = bool(result.get("terminated"))
truncated = bool(result.get("truncated"))
+ official_success = _result_official_success(result, terminated=terminated)
action_video_path = self._action_video_from_result(
result,
step=step,
@@ -691,6 +744,7 @@ def _apply_tool_result(self, event: ToolResultEvent) -> None:
"elapsed_s": log.get("elapsed_s"),
"terminated": terminated,
"truncated": truncated,
+ "official_success": official_success,
"action_video_path": action_video,
"action_video_artifact": action_video_artifact,
"has_action_video": bool(action_video_artifact or action_video_path),
@@ -699,6 +753,7 @@ def _apply_tool_result(self, event: ToolResultEvent) -> None:
self._timeline.append(item)
self._terminated = self._terminated or terminated
self._truncated = self._truncated or truncated
+ self._official_success = self._official_success or official_success
def _action_video_from_result(
self,
@@ -735,6 +790,10 @@ def on_step(self, record: StepRecord, *, step_offset: int = 0) -> None:
"elapsed_s": record.elapsed_s,
"terminated": record.terminated,
"truncated": record.truncated,
+ "official_success": _result_official_success(
+ record.result if isinstance(record.result, dict) else {},
+ terminated=record.terminated,
+ ),
"action_video_artifact": action_video,
"has_action_video": action_video is not None,
}
@@ -748,6 +807,9 @@ def on_step(self, record: StepRecord, *, step_offset: int = 0) -> None:
)
self._terminated = self._terminated or record.terminated
self._truncated = self._truncated or record.truncated
+ self._official_success = self._official_success or bool(
+ item["official_success"]
+ )
def _update_step_frames(self, record: StepRecord, *, display_step: int) -> None:
"""Load dashboard frame bytes from the step's canonical artifacts."""
@@ -962,6 +1024,7 @@ def snapshot(self) -> dict[str, Any]:
"state": self._visible_state_locked(),
"terminated": self._terminated,
"truncated": self._truncated,
+ "official_success": self._official_success,
"error": self._error,
"usage": dict(self._usage),
"runtime": self._runtime_snapshot(),
@@ -985,6 +1048,7 @@ def run_detail(self) -> dict[str, Any]:
"state": self._visible_state_locked(),
"terminated": self._terminated,
"truncated": self._truncated,
+ "official_success": self._official_success,
"error": self._error,
"usage": dict(self._usage),
"runtime": self._runtime_snapshot(),
diff --git a/rpent/dashboard/static/dashboard.js b/rpent/dashboard/static/dashboard.js
index dbe4b7985..e1ad577f1 100644
--- a/rpent/dashboard/static/dashboard.js
+++ b/rpent/dashboard/static/dashboard.js
@@ -745,12 +745,12 @@ function renderRuntimeStatus(runtime) {
container.replaceChildren(...items);
}
-function setResult(terminated, state) {
+function setResult(terminated, state, officialSuccess = false) {
const b = $("#resultBadge");
if (state === "succeeded" || terminated) {
b.style.display = "";
- b.className = "badge " + (terminated ? "b-ok" : "b-fail");
- b.textContent = terminated ? copy.solved : copy.notSolved;
+ b.className = "badge " + (officialSuccess ? "b-ok" : "b-fail");
+ b.textContent = officialSuccess ? copy.solved : copy.notSolved;
} else {
b.style.display = "none";
}
@@ -1128,7 +1128,7 @@ async function refreshMeta(opts = {}) {
const generationState = syncTaskGeneration(r);
if (generationState === "stale") return;
setBadge(r.state, r.control_error || r.error);
- setResult(r.terminated, r.state);
+ setResult(r.terminated, r.state, r.official_success);
renderRuntimeStatus(r.runtime);
interactionController.applySnapshot(r);
const currentTask = r.current_task;
@@ -1164,7 +1164,7 @@ function connectSSE() {
const generationState = syncTaskGeneration(sig);
if (generationState === "stale") return;
setBadge(sig.state, sig.control_error || sig.error);
- setResult(sig.terminated, sig.state);
+ setResult(sig.terminated, sig.state, sig.official_success);
renderRuntimeStatus(sig.runtime);
interactionController.applySnapshot(sig);
mediaState.frameAvailable = sig.frame_available || null;
diff --git a/rpent/memory/manager.py b/rpent/memory/manager.py
index 53c08b0be..6b9743416 100644
--- a/rpent/memory/manager.py
+++ b/rpent/memory/manager.py
@@ -275,9 +275,12 @@ def merge_memory(
audit = run_dir / f"{cell_tag}.json"
recipe = run_dir / f"{cell_tag}_recipe.jsonl"
+ if not recipe.exists():
+ # The generic EnvState command writer uses this filename.
+ recipe = run_dir / f"recipe_{cell_tag}.jsonl"
if solved and audit.exists() and recipe.exists():
audit_target = tiers["task"] / audit.name
- recipe_target = tiers["task"] / recipe.name
+ recipe_target = tiers["task"] / f"{cell_tag}_recipe.jsonl"
if not audit_target.exists() and not recipe_target.exists():
shutil.copy2(audit, audit_target)
shutil.copy2(recipe, recipe_target)
diff --git a/rpent/planner/codex.py b/rpent/planner/codex.py
index cf97f3b2c..232457344 100644
--- a/rpent/planner/codex.py
+++ b/rpent/planner/codex.py
@@ -219,7 +219,7 @@ def solve(
elapsed = time.time() - started
text = state.get("text", "") or output_path.read_text(errors="replace")
- error = error or recorder.error
+ error = _planner_result_error(error=error, recorder=recorder)
logger.info("Codex SDK finished in %.1fs", elapsed)
logger.info("output: %s", output_path)
@@ -236,6 +236,7 @@ def solve(
"raw_stream_path": str(raw_stream_path),
"last_message_path": str(last_message_path),
"last_message_chars": len(recorder.final_response or ""),
+ "nonfatal_recorder_errors": _nonfatal_recorder_error_count(recorder),
**recorder.stats(),
},
error=error,
@@ -445,9 +446,14 @@ def emit_user(text: str, *, initial: bool = False) -> None:
"raw_stream_path": str(raw_stream_path),
"last_message_path": str(last_message_path),
"last_message_chars": len(recorder.final_response or ""),
+ "nonfatal_recorder_errors": _nonfatal_recorder_error_count(recorder),
**recorder.stats(),
},
- error=error or session.error or recorder.error,
+ error=_planner_result_error(
+ error=error,
+ session_error=session.error,
+ recorder=recorder,
+ ),
)
# -- config builder ----------------------------------------------------
@@ -640,6 +646,7 @@ class _Recorder:
final_response: str | None = None
finish_result: dict[str, Any] | None = None
error: str | None = None
+ turn_failed: bool = False
def stats(self) -> dict[str, int]:
return {"turns_used": self.turns, "tool_calls": self.tool_calls, **self.usage}
@@ -726,6 +733,8 @@ def _render_turn_completed(self, turn: Any) -> str:
duration_ms = _get(turn, "duration_ms")
if error := _get(turn, "error"):
self.error = str(_get(error, "message", str(error)))
+ if status == "failed":
+ self.turn_failed = True
parts = ["[codex-result]", status]
if duration_ms is not None:
@@ -782,6 +791,35 @@ def _maybe_capture_finish(self, name: str, item: Any) -> None:
self.finish_result = {"_finish": True, **args}
+def _nonfatal_recorder_error_count(recorder: _Recorder) -> int:
+ return int(
+ recorder.finish_result is not None
+ and recorder.error is not None
+ and not recorder.turn_failed
+ )
+
+
+def _planner_result_error(
+ *,
+ error: str | None,
+ recorder: _Recorder,
+ session_error: str | None = None,
+) -> str | None:
+ """Return fatal planner error without treating post-finish stream noise as fatal."""
+
+ if error is not None:
+ return error
+ if session_error is not None:
+ return session_error
+ if _nonfatal_recorder_error_count(recorder):
+ logger.warning(
+ "Codex stream reported a non-fatal event after finish: %s",
+ recorder.error,
+ )
+ return None
+ return recorder.error
+
+
# ---------------------------------------------------------------------------
# Codex config overrides
# ---------------------------------------------------------------------------
diff --git a/rpent/robots/components/pi05_vla_client.py b/rpent/robots/components/pi05_vla_client.py
index d0fee3519..7d641d8e0 100644
--- a/rpent/robots/components/pi05_vla_client.py
+++ b/rpent/robots/components/pi05_vla_client.py
@@ -30,6 +30,16 @@
from rpent.robots.components.vla_client_base import BaseVLAClient
+_BEHAVIOR_ACTION_DIM = 23
+_BEHAVIOR_RAW_PROPRIO_SEGMENTS: dict[str, slice] = {
+ "left_arm": slice(158, 165),
+ "left_gripper": slice(193, 195),
+ "right_arm": slice(197, 204),
+ "right_gripper": slice(232, 234),
+ "trunk": slice(236, 240),
+ "base": slice(253, 256),
+}
+
# ---------------------------------------------------------------------------
# Obs encoder registry
# ---------------------------------------------------------------------------
@@ -65,10 +75,101 @@ def _batch_view(v):
}
+def _encode_obs_behavior(env_obs: dict) -> dict:
+ """BEHAVIOR/R1Pro single-env obs -> openpi batched wire obs."""
+
+ if not isinstance(env_obs, dict):
+ raise TypeError("BEHAVIOR observation must be a mapping")
+
+ main = np.asarray(env_obs.get("main_images"))
+ if main.ndim != 3 or main.shape[-1] != 3:
+ raise ValueError(f"main_images must be [H,W,3], got {main.shape}")
+ if main.dtype != np.uint8:
+ raise TypeError(f"main_images must have dtype uint8, got {main.dtype}")
+
+ wrists = np.asarray(env_obs.get("wrist_images"))
+ if wrists.ndim != 4 or wrists.shape[0] != 2 or wrists.shape[-1] != 3:
+ raise ValueError(f"wrist_images must be [2,H,W,3], got {wrists.shape}")
+ if wrists.dtype != np.uint8:
+ raise TypeError(f"wrist_images must have dtype uint8, got {wrists.dtype}")
+
+ states = np.asarray(env_obs.get("states"), dtype=np.float32)
+ if states.ndim != 1:
+ raise ValueError(f"states must be [raw_proprio_dim], got {states.shape}")
+ if not np.isfinite(states).all():
+ raise ValueError("states contains NaN or infinity")
+ _extract_behavior_policy_state(states)
+
+ task_description = env_obs.get("task_descriptions")
+ if isinstance(task_description, (list, tuple)):
+ instruction = next(
+ (
+ item.strip()
+ for item in task_description
+ if isinstance(item, str) and item.strip()
+ ),
+ "",
+ )
+ else:
+ instruction = str(task_description or "")
+
+ return {
+ "main_images": np.ascontiguousarray(main)[None],
+ "wrist_images": np.ascontiguousarray(wrists)[None],
+ "extra_view_images": None,
+ "states": np.ascontiguousarray(states)[None],
+ "task_descriptions": [instruction],
+ }
+
+
+def _validate_behavior_action_chunk(
+ actions: Any, *, max_horizon: int | None = None
+) -> np.ndarray:
+ array = np.asarray(actions, dtype=np.float32)
+ if array.ndim != 2 or array.shape[1] != _BEHAVIOR_ACTION_DIM or array.shape[0] < 1:
+ raise ValueError(
+ f"BEHAVIOR actions must be [T,{_BEHAVIOR_ACTION_DIM}], got {array.shape}"
+ )
+ if not np.isfinite(array).all():
+ raise ValueError("BEHAVIOR actions contain NaN or infinity")
+ if max_horizon is not None and array.shape[0] > int(max_horizon):
+ raise ValueError(
+ f"BEHAVIOR action horizon {array.shape[0]} exceeds {int(max_horizon)}"
+ )
+ return array
+
+
+def _extract_behavior_policy_state(raw_proprio: Any) -> np.ndarray:
+ raw = np.asarray(raw_proprio, dtype=np.float32)
+ if raw.ndim != 1 or raw.shape[0] < _BEHAVIOR_RAW_PROPRIO_SEGMENTS["base"].stop:
+ raise ValueError(
+ "raw R1Pro proprio must be a vector with at least "
+ f"{_BEHAVIOR_RAW_PROPRIO_SEGMENTS['base'].stop} values, got {raw.shape}"
+ )
+ compact = np.concatenate(
+ [
+ raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["base"]],
+ raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["trunk"]],
+ raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["left_arm"]],
+ raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["right_arm"]],
+ np.asarray([raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["left_gripper"]].sum()]),
+ np.asarray([raw[_BEHAVIOR_RAW_PROPRIO_SEGMENTS["right_gripper"]].sum()]),
+ ]
+ )
+ if compact.shape != (_BEHAVIOR_ACTION_DIM,):
+ raise ValueError(
+ f"compact policy state must be [{_BEHAVIOR_ACTION_DIM}], got {compact.shape}"
+ )
+ if not np.isfinite(compact).all():
+ raise ValueError("compact policy state contains NaN or infinity")
+ return compact
+
+
# NOTE: an embodiment registered here must also exist in the server's
# ``PI05_EMBODIMENTS`` (and ``PI05_ROBOT_PLATFORMS`` if it sets ROBOT_PLATFORM);
# the two registries are kept in sync manually.
_ENCODE_OBS: dict[str, Any] = {
+ "behavior": _encode_obs_behavior,
"libero": _encode_obs_libero,
}
@@ -93,6 +194,8 @@ def __init__(self, client, *, embodiment: str):
f"registered={list(_ENCODE_OBS)}"
)
self._embodiment = embodiment
+ if embodiment == "behavior":
+ self._TIMEOUT_S = {**self._TIMEOUT_S, "predict": 600.0}
# ---- obs encode (symmetric with server decode_obs_) ----
@@ -105,5 +208,11 @@ def encode_obs(self, env_obs: dict) -> dict:
def predict(self, env_obs: dict, options: dict | None = None) -> np.ndarray:
"""Encode obs, request ``vla.predict``, strip batch dim, return ``[chunk, action_dim]``."""
openpi_obs = self.encode_obs(env_obs)
- actions = super().predict(openpi_obs, options)
- return np.asarray(actions)[0]
+ actions = np.asarray(super().predict(openpi_obs, options))
+ if self._embodiment == "behavior":
+ if actions.ndim != 3 or actions.shape[0] != 1:
+ raise ValueError(
+ f"BEHAVIOR Pi0.5 actions must be [1,T,23], got {actions.shape}"
+ )
+ return _validate_behavior_action_chunk(actions[0])
+ return actions[0]
diff --git a/rpent/robots/components/pi05_vla_server.py b/rpent/robots/components/pi05_vla_server.py
index d0df87e81..78233b309 100644
--- a/rpent/robots/components/pi05_vla_server.py
+++ b/rpent/robots/components/pi05_vla_server.py
@@ -22,14 +22,17 @@
from __future__ import annotations
import argparse
+import hashlib
+import json
import os
import sys
+import threading
import time
-from typing import Any
+from contextlib import nullcontext
+from pathlib import Path
+from typing import Any, Mapping
import numpy as np
-import torch
-from omegaconf import OmegaConf
from rpent.robots.components.vla_facade_base import BaseVLAFacade
from rpent.utils.config import (
@@ -50,9 +53,128 @@
# Embodiment registry
# ---------------------------------------------------------------------------
+_BEHAVIOR_ACTION_DIM = 23
+
+
+def _checkpoint_sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as stream:
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _validate_checkpoint_manifest(
+ path: str | Path,
+ manifest_path: str | Path,
+) -> tuple[str, dict[str, Any]]:
+ requested = Path(path).expanduser()
+ try:
+ resolved = requested.resolve(strict=True)
+ except OSError as error:
+ raise ValueError(
+ f"your Pi0.5 model checkpoint is unavailable: {error}"
+ ) from error
+ if not resolved.is_dir():
+ raise ValueError(f"your Pi0.5 model checkpoint is not a directory: {resolved}")
+
+ manifest_file = Path(manifest_path).expanduser()
+ try:
+ manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
+ except OSError as error:
+ raise ValueError(f"checkpoint manifest is unavailable: {error}") from error
+ except json.JSONDecodeError as error:
+ raise ValueError(f"checkpoint manifest is invalid JSON: {error}") from error
+ if not isinstance(manifest, dict):
+ raise ValueError("checkpoint manifest must be a JSON object")
+
+ manifest_resolved_path = manifest.get("resolved_path")
+ if manifest_resolved_path is not None:
+ manifest_resolved = Path(str(manifest_resolved_path)).expanduser()
+ try:
+ manifest_resolved = manifest_resolved.resolve(strict=True)
+ except OSError as error:
+ raise ValueError(
+ f"checkpoint manifest resolved_path is unavailable: {error}"
+ ) from error
+ if manifest_resolved != resolved:
+ raise ValueError(
+ "checkpoint manifest resolved_path does not match --model-path: "
+ f"{manifest_resolved} != {resolved}"
+ )
+
+ files = manifest.get("files")
+ if not isinstance(files, dict) or not files:
+ raise ValueError("checkpoint manifest files must be a non-empty object")
+ for relative_path, requirement in files.items():
+ if not isinstance(relative_path, str) or not relative_path:
+ raise ValueError("checkpoint manifest file paths must be non-empty strings")
+ if Path(relative_path).is_absolute() or ".." in Path(relative_path).parts:
+ raise ValueError(
+ f"checkpoint manifest file path is not a safe relative path: {relative_path}"
+ )
+ if not isinstance(requirement, Mapping):
+ raise ValueError(
+ f"checkpoint manifest entry must be an object: {relative_path}"
+ )
+ try:
+ expected_size = int(requirement["size_bytes"])
+ expected_sha256 = str(requirement["sha256"])
+ except (KeyError, TypeError, ValueError) as error:
+ raise ValueError(
+ "checkpoint manifest entries require size_bytes and sha256: "
+ f"{relative_path}"
+ ) from error
+ candidate = resolved / relative_path
+ if candidate.is_symlink() or not candidate.is_file():
+ raise ValueError(f"checkpoint file is missing or unsafe: {candidate}")
+ size = candidate.stat().st_size
+ if size != expected_size:
+ raise ValueError(
+ "checkpoint file size mismatch for "
+ f"{relative_path}: expected {expected_size}, got {size}"
+ )
+ actual_sha256 = _checkpoint_sha256(candidate)
+ if actual_sha256 != expected_sha256:
+ raise ValueError(
+ "checkpoint file SHA256 mismatch for "
+ f"{relative_path}: expected {expected_sha256}, "
+ f"got {actual_sha256}"
+ )
+ return str(resolved), manifest
+
+
# NOTE: an embodiment added here must also be registered in the client's
# ``_ENCODE_OBS`` (obs encoding); the two registries are kept in sync manually.
PI05_EMBODIMENTS: dict[str, dict] = {
+ "behavior": {
+ "seed": 0,
+ "num_action_chunks": 32,
+ "action_dim": 32,
+ "use_proprio": True,
+ "num_steps": 4,
+ "add_value_head": False,
+ "openpi_data": {
+ "norm_stats_path": "assets/behavior-1k/2025-challenge-demos/norm_stats.json",
+ "extra_delta_transform": False,
+ "extract_state_from_proprio": True,
+ "use_all_wrist_images": True,
+ "use_quantile_norm": True,
+ },
+ "openpi": {
+ "config_name": "pi05_behavior",
+ "num_images_in_input": 3,
+ "action_dim": 32,
+ "action_horizon": 32,
+ "action_chunk": 32,
+ "action_env_dim": 23,
+ "num_steps": 4,
+ "add_value_head": False,
+ "noise_level": 0.0,
+ "noise_method": "flow_sde",
+ "joint_logprob": False,
+ },
+ },
"libero": {
"num_action_chunks": 5,
"action_dim": 7,
@@ -71,6 +193,7 @@
}
PI05_ROBOT_PLATFORMS: dict[str, str] = {
+ "behavior": "BEHAVIOR",
"libero": "LIBERO",
}
@@ -109,8 +232,18 @@ def build_model_cfg(model_path: str, emb_cfg: dict) -> Any:
for k, v in emb_cfg.items():
if k == "openpi":
cfg["openpi"].update(v)
+ elif k == "openpi_data":
+ cfg["openpi_data"] = dict(v)
else:
cfg[k] = v
+ openpi_data = cfg.get("openpi_data")
+ if isinstance(openpi_data, dict):
+ norm_stats_path = openpi_data.get("norm_stats_path")
+ if norm_stats_path is not None and not Path(norm_stats_path).is_absolute():
+ openpi_data["norm_stats_path"] = os.fspath(
+ Path(model_path) / norm_stats_path
+ )
+ from omegaconf import OmegaConf
return OmegaConf.create(cfg)
@@ -130,7 +263,13 @@ class Pi05VLAFacade(BaseVLAFacade):
Session-isolation is not supported (``reset_session`` is not registered).
"""
- def __init__(self, *, model_path: str, embodiment: str):
+ def __init__(
+ self,
+ *,
+ model_path: str,
+ embodiment: str,
+ checkpoint_manifest: str | None = None,
+ ):
if embodiment not in PI05_EMBODIMENTS:
raise ValueError(
f"unknown pi05 server embodiment: {embodiment!r}; "
@@ -138,15 +277,30 @@ def __init__(self, *, model_path: str, embodiment: str):
)
emb_cfg = PI05_EMBODIMENTS[embodiment]
self._embodiment = embodiment
+ self._model_path = os.fspath(Path(model_path).expanduser())
+ self._checkpoint_binding: dict[str, Any] | None = None
+ self._predict_lock = threading.Lock()
super().__init__()
+ import torch
from rlinf.models.embodiment.openpi import get_model as get_openpi_model
platform = PI05_ROBOT_PLATFORMS.get(embodiment)
if platform is not None:
os.environ.setdefault("ROBOT_PLATFORM", platform)
- cfg = build_model_cfg(model_path=model_path, emb_cfg=emb_cfg)
+ if checkpoint_manifest is not None:
+ self._model_path, self._checkpoint_binding = _validate_checkpoint_manifest(
+ model_path,
+ checkpoint_manifest,
+ )
+ seed = emb_cfg.get("seed")
+ if seed is not None:
+ torch.manual_seed(int(seed))
+ if torch.cuda.is_available():
+ torch.cuda.manual_seed_all(int(seed))
+
+ cfg = build_model_cfg(model_path=self._model_path, emb_cfg=emb_cfg)
t0 = time.time()
logger.info(
"loading Pi0.5 (embodiment=%s, model_path=%s) ...",
@@ -154,7 +308,29 @@ def __init__(self, *, model_path: str, embodiment: str):
cfg["model_path"],
)
self._model = get_openpi_model(cfg, torch_dtype=None).cuda().eval()
- logger.info("model ready in %.1fs", time.time() - t0)
+ self._model_meta = {
+ "status": "ok",
+ "runtime": "pi05_vla",
+ "embodiment": embodiment,
+ "config_name": emb_cfg.get("openpi", {}).get("config_name"),
+ "action_horizon": emb_cfg.get("openpi", {}).get("action_chunk")
+ or emb_cfg.get("num_action_chunks"),
+ "action_dim": emb_cfg.get("openpi", {}).get("action_env_dim")
+ or emb_cfg.get("action_dim"),
+ "model_path": self._model_path,
+ "device": "cuda",
+ "load_elapsed_s": round(time.time() - t0, 2),
+ }
+ if self._checkpoint_binding is not None:
+ self._model_meta["checkpoint_binding"] = self._checkpoint_binding
+ logger.info("model ready in %.1fs", self._model_meta["load_elapsed_s"])
+
+ def _builtin_dispatch(
+ self, method: str, args: tuple[Any, ...], kwargs: dict[str, Any]
+ ) -> Any:
+ if method == "healthz":
+ return {**self._model_meta, "pid": os.getpid()}
+ return super()._builtin_dispatch(method, args, kwargs)
# ---- inference ----
@@ -164,10 +340,27 @@ def predict(self, obs: dict, options: dict | None = None) -> np.ndarray:
The caller (client) is responsible for encoding env-native obs into
the openpi wire format (see ``Pi05VLAClient.encode_obs``).
"""
+ if self._embodiment == "behavior":
+ if options is None:
+ options = {}
+ if not isinstance(options, dict):
+ raise TypeError("VLA options must be a mapping")
+ unexpected = set(options) - {"mode"}
+ if unexpected:
+ raise ValueError(f"unsupported VLA options: {sorted(unexpected)!r}")
mode = (options or {}).get("mode", "eval")
- with torch.no_grad():
- actions, _ = self._model.predict_action_batch(obs, mode=mode)
- return (
+ if self._embodiment == "behavior":
+ if mode != "eval":
+ raise ValueError("BEHAVIOR VLA inference mode must be 'eval'")
+ predict_kwargs = {"mode": mode}
+ if self._embodiment == "behavior":
+ predict_kwargs["compute_values"] = False
+ lock = self._predict_lock if self._embodiment == "behavior" else nullcontext()
+ import torch
+
+ with lock, torch.no_grad():
+ actions, _ = self._model.predict_action_batch(obs, **predict_kwargs)
+ result = (
actions.detach().cpu().numpy()
if (
hasattr(actions, "detach")
@@ -176,6 +369,19 @@ def predict(self, obs: dict, options: dict | None = None) -> np.ndarray:
)
else np.asarray(actions)
).astype(np.float32)
+ if self._embodiment == "behavior":
+ if (
+ result.ndim != 3
+ or result.shape[0] != 1
+ or result.shape[1] < 1
+ or result.shape[2] != _BEHAVIOR_ACTION_DIM
+ or not np.isfinite(result).all()
+ ):
+ raise ValueError(
+ "Pi0.5 returned invalid "
+ f"[1,T,{_BEHAVIOR_ACTION_DIM}] shape {result.shape}"
+ )
+ return result
# ---------------------------------------------------------------------------
@@ -209,6 +415,14 @@ def main() -> None:
default=None,
help="Pi0.5 checkpoint (defaults to PI05_CHECKPOINT_PATH env)",
)
+ p.add_argument(
+ "--checkpoint-manifest",
+ default=None,
+ help=(
+ "Optional generic JSON manifest describing required checkpoint files "
+ "and SHA-256 values."
+ ),
+ )
args = p.parse_args()
if args.cuda_device is not None:
@@ -229,7 +443,11 @@ def main() -> None:
"path via --model-path or the environment."
)
- facade = Pi05VLAFacade(model_path=model_path, embodiment=args.embodiment)
+ facade = Pi05VLAFacade(
+ model_path=model_path,
+ embodiment=args.embodiment,
+ checkpoint_manifest=args.checkpoint_manifest,
+ )
facade.serve(
transport=args.transport,
host=args.host,
diff --git a/rpent/robots/robot_spec.py b/rpent/robots/robot_spec.py
index 21ec56b13..11b8a25aa 100644
--- a/rpent/robots/robot_spec.py
+++ b/rpent/robots/robot_spec.py
@@ -65,3 +65,14 @@ class RobotSpec:
dashboard: dict[str, Any] | None = None
memory_repo_id: str = "RLinf/RPent-memory"
finalize_run: RunFinalizer | None = None
+ # Optional session-scoped runtime refresh before Explore toolkit creation.
+ # Mutate the runner's owned-daemon list and return primitive kwargs updates.
+ # init_runtime(..., None) remains responsible for shared runtime startup.
+ # Opts into the CLI Explore toolkit mode/state-dir and solved/recipe contract.
+ on_explore_session: (
+ Callable[
+ [argparse.Namespace, Path, DashboardEventSink, list["ProcessDaemon"]],
+ dict[str, Any],
+ ]
+ | None
+ ) = None
diff --git a/rpent/session/__init__.py b/rpent/session/__init__.py
index 56e6d989f..4e51548e0 100644
--- a/rpent/session/__init__.py
+++ b/rpent/session/__init__.py
@@ -14,6 +14,18 @@
"""Single-session, mutable state (EnvState)."""
-from rpent.session.base import EnvState, StepRecord
+from rpent.session.base import (
+ EnvState,
+ StepRecord,
+ VideoArtifactWriter,
+ recipe_commands_from_states,
+ write_command_recipe_from_states,
+)
-__all__ = ["EnvState", "StepRecord"]
+__all__ = [
+ "EnvState",
+ "StepRecord",
+ "VideoArtifactWriter",
+ "write_command_recipe_from_states",
+ "recipe_commands_from_states",
+]
diff --git a/rpent/session/base.py b/rpent/session/base.py
index 1e8b3146b..57e9d0ee7 100644
--- a/rpent/session/base.py
+++ b/rpent/session/base.py
@@ -292,6 +292,36 @@ def exists(self, name: str, *, step: int | None = -1) -> bool:
return False
return self._artifact_file(name, resolved_step).exists()
+ def _register_artifact(self, name: str, step: int | None) -> None:
+ if step is not None:
+ self._record_for(step).artifacts.add(name)
+ else:
+ self._run_artifacts.add(name)
+ if not self._step_open:
+ self._write_manifest()
+
+ def open_video_writer(
+ self,
+ name: str,
+ *,
+ step: int | None = None,
+ fps: int = 20,
+ max_frames: int | None = None,
+ ) -> "VideoArtifactWriter":
+ """Open a streaming MP4 writer for an EnvState artifact."""
+
+ resolved_step = self._resolve_read_step(step)
+ path = self._artifact_file(name, resolved_step)
+ if path.suffix.lower() != ".mp4":
+ raise ValueError("video artifacts must use .mp4")
+ return VideoArtifactWriter(
+ self,
+ name=name,
+ step=resolved_step,
+ fps=fps,
+ max_frames=max_frames,
+ )
+
# -- step records ----------------------------------------------------
@contextmanager
@@ -359,3 +389,137 @@ def get(self, step: int = -1) -> StepRecord:
def records(self) -> list[StepRecord]:
return copy.deepcopy(self._steps)
+
+
+def recipe_commands_from_states(env_state: EnvState) -> list[dict[str, Any]]:
+ """Return replayable top-level tool commands from stateful records."""
+
+ commands: list[dict[str, Any]] = []
+ for record in env_state.records():
+ command = record.command
+ if not isinstance(command, dict) or command.get("action") is None:
+ continue
+ if isinstance(record.result, dict) and record.result.get("error"):
+ continue
+ commands.append(copy.deepcopy(command))
+ return commands
+
+
+def write_command_recipe_from_states(
+ env_state: EnvState,
+ recipe_tag: str,
+ *,
+ output_state: EnvState | None = None,
+) -> str | None:
+ """Write a generic ``recipe_.jsonl`` command-sequence artifact.
+
+ This shared helper exports top-level replay commands from generic
+ :class:`EnvState` records. It is intentionally distinct from robot-specific
+ recipe writers such as LIBERO's ``write_recipe_from_states``, which also
+ applies reset-window filtering, segment artifacts, and solved gating.
+ """
+
+ tag = str(recipe_tag).strip()
+ if not tag:
+ raise ValueError("recipe_tag must be a non-empty string")
+ commands = recipe_commands_from_states(env_state)
+ if not commands:
+ return None
+ target_state = output_state or env_state
+ name = f"recipe_{tag}.jsonl"
+ saved = target_state.save(name, commands, step=None)
+ if saved is None:
+ raise RuntimeError(f"failed to write {name}")
+ return str(target_state.artifact_path(saved, step=None))
+
+
+class VideoArtifactWriter:
+ """Streaming MP4 writer that registers the artifact only on successful close."""
+
+ def __init__(
+ self,
+ env_state: EnvState,
+ *,
+ name: str,
+ step: int | None,
+ fps: int,
+ max_frames: int | None,
+ ) -> None:
+ if not isinstance(fps, int) or fps <= 0:
+ raise ValueError("fps must be a positive integer")
+ if max_frames is not None and int(max_frames) <= 0:
+ raise ValueError("max_frames must be positive when provided")
+ self._env_state = env_state
+ self._name = env_state._validate_name(name)
+ self._step = step
+ self._fps = fps
+ self._max_frames = None if max_frames is None else int(max_frames)
+ self._destination = env_state._artifact_file(self._name, step)
+ self._temporary = self._destination.with_name(
+ f".{self._destination.stem}.{os.getpid()}.{id(self)}.tmp"
+ f"{self._destination.suffix}"
+ )
+ self._writer: Any | None = None
+ self._closed = False
+ self._aborted = False
+ self.frames_written = 0
+ self.frames_dropped = 0
+
+ def append(self, frame: Any) -> bool:
+ """Append one RGB frame; return False when the configured cap is reached."""
+
+ if self._closed:
+ raise RuntimeError("video writer is closed")
+ if self._aborted:
+ return False
+ if self._max_frames is not None and self.frames_written >= self._max_frames:
+ self.frames_dropped += 1
+ return False
+ array = np.asarray(frame)
+ if array.ndim != 3 or array.shape[2] < 3:
+ raise ValueError("video frame must have shape [H, W, C>=3]")
+ array = array[..., :3]
+ if array.dtype != np.uint8:
+ array = np.clip(array, 0, 255).astype(np.uint8)
+ if self._writer is None:
+ self._destination.parent.mkdir(parents=True, exist_ok=True)
+ self._writer = imageio.get_writer(self._temporary, fps=self._fps)
+ self._writer.append_data(np.ascontiguousarray(array))
+ self.frames_written += 1
+ return True
+
+ def close(self) -> str | None:
+ """Finalize the video and register it in the EnvState manifest."""
+
+ if self._closed:
+ return self._name if self._destination.exists() else None
+ self._closed = True
+ try:
+ if self._writer is not None:
+ self._writer.close()
+ self._writer = None
+ if self._aborted or self.frames_written <= 0:
+ self._temporary.unlink(missing_ok=True)
+ return None
+ os.replace(self._temporary, self._destination)
+ self._env_state._register_artifact(self._name, self._step)
+ return self._name
+ except Exception:
+ self.abort()
+ raise
+ finally:
+ self._temporary.unlink(missing_ok=True)
+
+ def abort(self) -> None:
+ """Close and remove the temporary file without publishing the artifact."""
+
+ if self._aborted:
+ return
+ self._aborted = True
+ try:
+ if self._writer is not None:
+ self._writer.close()
+ self._writer = None
+ finally:
+ self._closed = True
+ self._temporary.unlink(missing_ok=True)
diff --git a/rpent/tools/toolkit.py b/rpent/tools/toolkit.py
index 871673470..21ca334c1 100644
--- a/rpent/tools/toolkit.py
+++ b/rpent/tools/toolkit.py
@@ -134,9 +134,14 @@ def _build_content_blocks(self) -> list[dict[str, Any]]:
result_for_text = dict(result)
image = result_for_text.pop("_image_bytes", None)
+ depth_image = result_for_text.pop("_depth_image_bytes", None)
image_cam = result_for_text.pop("_image_cam_bytes", None)
image_nav = result_for_text.pop("_image_nav_bytes", None)
image_wrist = result_for_text.pop("_image_wrist_bytes", None)
+ image_left_wrist = result_for_text.pop("_image_left_wrist_bytes", None)
+ depth_left_wrist = result_for_text.pop("_depth_left_wrist_bytes", None)
+ image_right_wrist = result_for_text.pop("_image_right_wrist_bytes", None)
+ depth_right_wrist = result_for_text.pop("_depth_right_wrist_bytes", None)
text = json.dumps(result_for_text, indent=2, default=str)
text = _truncate_utf8(
text,
@@ -161,12 +166,22 @@ def _add_image_bytes(data_bytes: bytes) -> None:
if image:
_add_image_bytes(image)
+ if depth_image:
+ _add_image_bytes(depth_image)
if image_cam:
_add_image_bytes(image_cam)
if image_nav:
_add_image_bytes(image_nav)
if image_wrist:
_add_image_bytes(image_wrist)
+ if image_left_wrist:
+ _add_image_bytes(image_left_wrist)
+ if depth_left_wrist:
+ _add_image_bytes(depth_left_wrist)
+ if image_right_wrist:
+ _add_image_bytes(image_right_wrist)
+ if depth_right_wrist:
+ _add_image_bytes(depth_right_wrist)
return blocks
diff --git a/rpent/utils/daemon.py b/rpent/utils/daemon.py
index da7e53a70..30d6eaac9 100644
--- a/rpent/utils/daemon.py
+++ b/rpent/utils/daemon.py
@@ -45,7 +45,11 @@ def watch_parent_death(on_death: Callable[[], None]) -> None:
def _watch() -> None:
try:
- sys.stdin.buffer.read()
+ # A daemon thread must not hold BufferedReader's lock while Python
+ # finalizes stdin (that aborts the interpreter on RPC shutdown).
+ fd = sys.stdin.fileno()
+ while os.read(fd, 65536):
+ pass
except Exception:
pass
on_death()
diff --git a/tests/unit_tests/robots/behavior/test_behavior_contracts.py b/tests/unit_tests/robots/behavior/test_behavior_contracts.py
new file mode 100644
index 000000000..03e07989f
--- /dev/null
+++ b/tests/unit_tests/robots/behavior/test_behavior_contracts.py
@@ -0,0 +1,1387 @@
+# Copyright 2026 The RPent Authors.
+#
+# 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
+#
+# https://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.
+
+"""Offline contracts for the BEHAVIOR toolkit."""
+
+from __future__ import annotations
+
+import json
+import os
+import signal
+import socket
+import sys
+import threading
+import time
+from pathlib import Path
+from typing import Any
+
+import numpy as np
+import pytest
+
+from robots.behavior import build_memory_cli
+from robots.behavior.dino_v2.client import BehaviorDinoClient
+from robots.behavior.dino_v2.encoder import DINOV2_DIMENSION
+from robots.behavior.dino_v2.server import BehaviorDinoFacade
+from robots.behavior.env_client import BehaviorEnvClient
+from robots.behavior.env_server import BehaviorEnvFacade
+from robots.behavior.rlinf_env import (
+ GRIPPER_CLOSE_COMMAND,
+ GRIPPER_COMMAND_CONTROL_CYCLES,
+ GRIPPER_OPEN_COMMAND,
+ OfficialBehaviorBackend,
+)
+from robots.behavior.robot_spec import get_toolkit
+from robots.behavior.schemas import (
+ BEHAVIOR_TOOL_NAMES,
+ ENV_ACTION_SEGMENTS,
+ MOVE_TO_SPEC,
+ RAW_PROPRIO_SEGMENTS,
+ validate_action_chunk,
+)
+from robots.behavior.toolkit import BehaviorToolkit
+from robots.behavior.tools import BehaviorPrimitives
+from rpent.dashboard.events import NullDashboardEventSink
+from rpent.memory import MemoryManager
+from rpent.robots import RunConfig
+from rpent.utils.daemon import ProcessDaemon, pick_free_port
+from rpent.utils.rpc.http_rpc import HttpRpcClient
+
+EXPECTED_TOOLS = (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "navigate_to",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+)
+
+
+def test_episode_memory_is_structured_in_public_results():
+ from robots.behavior.memory.index import empty_episode_memory_index
+
+ class Encoder:
+ def encode_batch(self, images):
+ vector = np.zeros(DINOV2_DIMENSION, dtype=np.float32)
+ vector[0] = 1
+ return [vector if image is not None else None for image in images]
+
+ class Env:
+ def observe(self, **kwargs):
+ return {"status": "ok", "info": {"done": {"success": False}}}
+
+ primitives = BehaviorPrimitives(
+ env=Env(),
+ initial_observation={"main_images": np.zeros((8, 8, 3), dtype=np.uint8)},
+ episode_memory_index=empty_episode_memory_index(),
+ dino_component=Encoder(),
+ )
+ decision = primitives.snapshot()["episode_memory"]
+ assert isinstance(decision, dict)
+ assert decision["decision"] == "record_new"
+ assert decision["candidate_count_after_task_filter"] == 0
+ assert decision["stage_inference"] is None
+ assert primitives.observe(camera="head")["episode_memory"] == decision
+ assert json.loads(json.dumps(decision)) == decision
+
+
+@pytest.mark.parametrize(
+ ("task_name", "mode", "seed", "instruction"),
+ [
+ (
+ "turning_on_radio",
+ "explore",
+ 0,
+ "Turn on the radio receiver that's on the table in the living room.",
+ ),
+ (
+ "picking_up_trash",
+ "explore",
+ 0,
+ "Put the three can of soda from the living room inside the tash can "
+ "in the kitchen.",
+ ),
+ (
+ "picking_up_trash",
+ "eval",
+ 10,
+ "Put the three can of soda from the living room inside the tash can "
+ "in the kitchen.",
+ ),
+ ],
+)
+@pytest.mark.parametrize("batched", [False, True])
+def test_runtime_preserves_rlinf_task_language(
+ tmp_path: Path,
+ task_name: str,
+ mode: str,
+ seed: int,
+ instruction: str,
+ batched: bool,
+) -> None:
+ import argparse
+
+ from robots.behavior import runtime
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output-dir")
+ runtime.add_cli_args(parser, use_dashboard=False)
+ args = parser.parse_args(
+ [
+ "--task-name",
+ task_name,
+ "--public-seed",
+ str(seed),
+ "--behavior-mode",
+ mode,
+ "--behavior-repo",
+ str(tmp_path / "RLinf"),
+ "--output-dir",
+ str(tmp_path / "output"),
+ ]
+ )
+ config = runtime.parse_config(args)
+ meta = runtime.env_runtime_contract(args)
+ assert meta["task_language"] == instruction
+ for key in ("task_language", "task_instruction", "instruction"):
+ assert config.prompt_vars[key] == instruction
+
+ class Rpc:
+ language = instruction
+
+ def call(self, method, **kwargs):
+ if method == "env.get_env_meta":
+ return meta
+ assert method == "env.reset"
+ text = [self.language] if batched else self.language
+ return {"task_descriptions": text}, {"done": {"success": False}}
+
+ rpc = Rpc()
+ connected = runtime._connect_env(args, rpc, config.output_dir)
+ expected = [instruction] if batched else instruction
+ assert connected["initial_observation"]["task_descriptions"] == expected
+ assert connected["env"].official_success_latched is False
+
+ rpc.language = "Put the three soda cans from the living room inside the trash can in the kitchen."
+ with pytest.raises(RuntimeError, match="task language does not match TaskSpec"):
+ runtime._connect_env(args, rpc, config.output_dir)
+
+
+def test_env_endpoint_discovery_uses_actual_bind_and_ignores_old_log(tmp_path: Path):
+ from robots.behavior.runtime import _wait_for_server_endpoint
+
+ log = tmp_path / "env.log"
+ log.write_text("RPC server listening on http://127.0.0.1:1\n")
+ offset = log.stat().st_size
+ daemon = ProcessDaemon(
+ "test_env",
+ [
+ sys.executable,
+ "-c",
+ "from types import SimpleNamespace; "
+ "from robots.behavior.env_server import BehaviorEnvFacade; "
+ "print('Ray started; no application endpoint yet', flush=True); "
+ "BehaviorEnvFacade(backend=SimpleNamespace(close=lambda: None), "
+ "meta={'task_language': 'test'}).serve("
+ "transport='http', host='127.0.0.1', port=0, parent_watch=True)",
+ ],
+ log_path=str(log),
+ )
+ daemon.start()
+ rpc = None
+ try:
+ endpoint = _wait_for_server_endpoint(daemon, log_offset=offset)
+ assert endpoint != "http://127.0.0.1:1"
+ rpc = HttpRpcClient(endpoint)
+ assert rpc.call("healthz") == {"status": "ok"}
+ assert rpc.call("env.get_env_meta") == {"task_language": "test"}
+ assert rpc.call("shutdown") == {"ok": True}
+ finally:
+ if rpc is not None:
+ rpc.close()
+ daemon.stop()
+
+
+def test_env_endpoint_discovery_reports_early_exit(tmp_path: Path):
+ from types import SimpleNamespace
+
+ from robots.behavior.runtime import _wait_for_server_endpoint
+
+ log = tmp_path / "env.log"
+ log.write_text("initialization failed\n")
+ daemon = SimpleNamespace(log_path=str(log), name="test_env", poll=lambda: 2)
+ with pytest.raises(RuntimeError, match="exited with code 2"):
+ _wait_for_server_endpoint(daemon)
+
+
+class _FakeEnv:
+ total_env_steps = 0
+ official_success_latched = False
+ official_success_receipt = None
+
+ def __init__(self) -> None:
+ self.last_move: dict[str, Any] | None = None
+ self.gripper_calls: list[tuple[str, dict[str, Any]]] = []
+
+ def move_to(self, **kwargs: Any) -> dict[str, Any]:
+ self.last_move = kwargs
+ return {"status": "failed", "stop_reason": "motion_unavailable"}
+
+ def close_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ self.gripper_calls.append(("close_gripper", kwargs))
+ return {"status": "success"}
+
+ def open_gripper(self, **kwargs: Any) -> dict[str, Any]:
+ self.gripper_calls.append(("open_gripper", kwargs))
+ return {"status": "success"}
+
+
+class _FakeRpcClient:
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, dict[str, Any]]] = []
+
+ def call(
+ self,
+ method: str,
+ *,
+ args: tuple = (),
+ kwargs: dict[str, Any] | None = None,
+ timeout_s: float | None = None,
+ ) -> dict[str, Any]:
+ self.calls.append((method, kwargs or {}))
+ if method == "env.get_env_meta":
+ return {}
+ if method == "dino.get_meta":
+ return {
+ "runtime": "behavior_dino",
+ "dimension": DINOV2_DIMENSION,
+ }
+ return {"status": "success"}
+
+
+class _FakeModel:
+ def predict(self, observation: dict[str, Any], *, options: dict[str, Any]) -> Any:
+ assert observation["task_descriptions"] == "turn on the radio"
+ assert options == {"mode": "eval"}
+ return np.zeros((32, 23), dtype=np.float32)
+
+
+class _FakeChunkEnv:
+ total_env_steps = 0
+ official_success_latched = False
+ official_success_receipt = None
+
+ def __init__(self) -> None:
+ self.return_all_frames: list[bool] = []
+
+ def chunk_step(
+ self,
+ actions: Any,
+ *,
+ return_all_frames: bool = False,
+ ) -> tuple[Any, float, bool, bool, dict[str, Any]]:
+ array = np.asarray(actions)
+ self.return_all_frames.append(bool(return_all_frames))
+ self.total_env_steps += int(array.shape[0])
+ frames = [
+ {
+ "main_images": np.full((16, 16, 3), idx, dtype=np.uint8),
+ "task_descriptions": "turn on the radio",
+ }
+ for idx in range(int(array.shape[0]))
+ ]
+ obs: Any = frames if return_all_frames else frames[-1]
+ return (
+ obs,
+ 0.0,
+ False,
+ False,
+ {
+ "executed_steps": int(array.shape[0]),
+ "_rpent": {"total_env_steps": self.total_env_steps},
+ },
+ )
+
+
+class _FakeOfficialBehaviorEnv:
+ def __init__(
+ self,
+ *_args: Any,
+ terminated_on_step: int | None = None,
+ success_on_step: int | None = None,
+ **_kwargs: Any,
+ ) -> None:
+ self.actions: list[np.ndarray] = []
+ self.closed = False
+ self.terminated_on_step = terminated_on_step
+ self.success_on_step = success_on_step
+ self.raw = np.zeros(256, dtype=np.float32)
+ self.raw[RAW_PROPRIO_SEGMENTS["trunk"]] = np.asarray(
+ [0.11, 0.12, 0.13, 0.14],
+ dtype=np.float32,
+ )
+ self.raw[RAW_PROPRIO_SEGMENTS["left_arm"]] = np.linspace(
+ 0.21,
+ 0.27,
+ 7,
+ dtype=np.float32,
+ )
+ self.raw[RAW_PROPRIO_SEGMENTS["right_arm"]] = np.linspace(
+ -0.31,
+ -0.37,
+ 7,
+ dtype=np.float32,
+ )
+ self.raw[RAW_PROPRIO_SEGMENTS["left_gripper"]] = 0.05
+ self.raw[RAW_PROPRIO_SEGMENTS["right_gripper"]] = 0.06
+
+ def _obs(self) -> dict[str, Any]:
+ return {
+ "main_images": np.zeros((8, 8, 3), dtype=np.uint8),
+ "wrist_images": np.zeros((2, 8, 8, 3), dtype=np.uint8),
+ "states": self.raw.copy(),
+ "task_descriptions": "turn on the radio",
+ }
+
+ def reset_raw(self, *, env_idx: int = 0) -> tuple[dict[str, Any], dict[str, Any]]:
+ assert env_idx == 0
+ return self._obs(), {"done": {"success": False}}
+
+ def step_raw(
+ self,
+ action: Any,
+ *,
+ env_idx: int = 0,
+ ) -> tuple[dict[str, Any], float, bool, bool, dict[str, Any]]:
+ assert env_idx == 0
+ action_array = np.asarray(action, dtype=np.float32)
+ self.actions.append(action_array.copy())
+ step_index = len(self.actions)
+ terminated = self.terminated_on_step == step_index
+ success = self.success_on_step == step_index
+ return self._obs(), 0.0, terminated, False, {"done": {"success": success}}
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class _ThreadRecordingBehaviorEnvFacade(BehaviorEnvFacade):
+ def __init__(self) -> None:
+ super().__init__(backend=object(), meta={"task_language": "test"})
+ self.serve_thread_id: int | None = None
+ self.business_thread_id: int | None = None
+
+ def serve(self, **kwargs: Any) -> None:
+ self.serve_thread_id = threading.get_ident()
+ super().serve(**kwargs)
+
+ def get_env_meta(self) -> dict[str, Any]:
+ self.business_thread_id = threading.get_ident()
+ return super().get_env_meta()
+
+
+def _both_hand_request() -> dict[str, Any]:
+ return {
+ "hand": "both",
+ "targets": {
+ "left": {"delta_xyz": [0.01, 0.0, 0.0], "frame": "world"},
+ "right": {"delta_xyz": [-0.01, 0.0, 0.0], "frame": "eef"},
+ },
+ "visual_hand_checks": {
+ "left": {
+ "camera": "left_wrist",
+ "frame_id": "left-frame",
+ "selected_hand": "left",
+ "assessment": "selected_hand_visually_confirmed",
+ },
+ "right": {
+ "camera": "right_wrist",
+ "frame_id": "right-frame",
+ "selected_hand": "right",
+ "assessment": "selected_hand_visually_confirmed",
+ },
+ },
+ }
+
+
+def _visual_check(hand: str) -> dict[str, str]:
+ return {
+ "camera": f"{hand}_wrist",
+ "frame_id": f"{hand}-frame",
+ "selected_hand": hand,
+ "assessment": "selected_hand_visually_confirmed",
+ }
+
+
+def _official_backend(
+ tmp_path: Path,
+ fake_env: _FakeOfficialBehaviorEnv | None = None,
+) -> tuple[OfficialBehaviorBackend, _FakeOfficialBehaviorEnv]:
+ env = fake_env or _FakeOfficialBehaviorEnv()
+ backend = OfficialBehaviorBackend(
+ meta={
+ "task_name": "turning_on_radio",
+ "task_language": "turn on the radio",
+ "activity_definition_id": 0,
+ "activity_instance_id": 242,
+ "public_seed": 0,
+ "scene_model": "house_double_floor_lower",
+ "max_episode_steps": 64,
+ },
+ output_dir=tmp_path,
+ behavior_env_cls=lambda *_args, **_kwargs: env,
+ cfg=object(),
+ )
+ backend.reset()
+ return backend, env
+
+
+def _port_accepts_connections(port: int) -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
+ probe.settimeout(0.1)
+ return probe.connect_ex(("127.0.0.1", port)) == 0
+
+
+def test_public_behavior_surface_is_exactly_nine_tools() -> None:
+ assert BEHAVIOR_TOOL_NAMES == EXPECTED_TOOLS
+
+
+def test_pixel_projection_uses_observed_depth_and_rejects_old_frame(tmp_path):
+ backend, _ = _official_backend(tmp_path)
+ frames = {
+ camera: {
+ "rgb": np.zeros((3, 3, 3), dtype=np.uint8),
+ "depth": np.ones((3, 3), dtype=np.float32) * 2,
+ "intrinsic": np.array([[2.0, 0, 1], [0, 2, 1], [0, 0, 1]]),
+ "camera_to_world": np.eye(4),
+ }
+ for camera in ("head", "left_wrist", "right_wrist")
+ }
+ backend._call_actor = lambda _function: frames
+ observation = backend.observe(camera="head")
+ result = backend.pixel_to_world(
+ camera="head", frame_id=observation["frame_id"], u=1, v=1
+ )
+ assert result["world_xyz"] == [0.0, 0.0, -2.0]
+ assert result["task_success"] is False
+ assert (
+ backend._projection(result["projection_id"])["world_xyz"] == result["world_xyz"]
+ )
+ backend.chunk_step(backend._hold_action_from_current_proprio()[None, :])
+ assert (
+ backend.pixel_to_world(
+ camera="head", frame_id=observation["frame_id"], u=1, v=1
+ )["stop_reason"]
+ == "stale_frame"
+ )
+
+
+@pytest.mark.parametrize("hand", ["left", "both"])
+def test_planned_motion_preserves_unselected_joint_commands(tmp_path, hand):
+ from types import SimpleNamespace
+
+ backend, env = _official_backend(tmp_path)
+ hold = backend._hold_action_from_current_proprio().copy()
+ hands = ("left", "right") if hand == "both" else (hand,)
+ poses = {
+ h: {"position": np.zeros(3), "quaternion_xyzw": [0, 0, 0, 1]}
+ for h in ("left", "right")
+ }
+ state = {"hands": poses, "control_dt": 1 / 60}
+ backend._call_actor = lambda _: state
+ backend._get_motion_state = lambda: state
+ names = [f"{h}_arm_joint{i}" for h in hands for i in range(1, 8)]
+ trajectory = np.ones((2, len(names)), dtype=np.float32) * 0.1
+ backend._motion_planner = SimpleNamespace(
+ plan=lambda *_: {
+ "success": True,
+ "joint_names": names,
+ "positions": trajectory,
+ "dt": 1 / 60,
+ }
+ )
+ request = (
+ _both_hand_request()
+ if hand == "both"
+ else {"hand": hand, "target": {"delta_xyz": [0, 0, 0], "frame": "world"}}
+ )
+ if hand == "both":
+ for target in request["targets"].values():
+ target["delta_xyz"] = [0, 0, 0]
+ result = backend.move_to(**request)
+ assert result["primitive_success"] is True
+ assert result["task_success"] is False
+ for action in env.actions:
+ expected = hold.copy()
+ for selected in hands:
+ expected[ENV_ACTION_SEGMENTS[f"{selected}_arm"]] = 0.1
+ np.testing.assert_allclose(action, expected)
+
+
+def test_failed_plan_does_not_execute_actions(tmp_path):
+ from types import SimpleNamespace
+
+ backend, env = _official_backend(tmp_path)
+ backend._call_actor = lambda _: {
+ "hands": {"left": {"position": np.zeros(3), "quaternion_xyzw": [0, 0, 0, 1]}}
+ }
+ backend._motion_planner = SimpleNamespace(
+ plan=lambda *_: {
+ "success": False,
+ "stop_reason": "planning_failed",
+ "details": "collision",
+ }
+ )
+ result = backend.move_to(
+ hand="left", target={"delta_xyz": [0, 0, 0.03], "frame": "world"}
+ )
+ assert result["stop_reason"] == "planning_failed"
+ assert not env.actions
+
+
+def test_navigation_swept_footprint_rejects_obstacles():
+ from robots.behavior.motion import navigation_collision
+
+ state = {
+ "base_position": np.zeros(3),
+ "robot_aabb": [[-0.1, -0.1, 0], [0.1, 0.1, 0.5]],
+ "obstacles": {"box": {"low": [0.4, -0.1, 0.1], "high": [0.6, 0.1, 0.3]}},
+ }
+ assert navigation_collision(state, np.array([1.0, 0, 0])) == "box"
+ assert navigation_collision(state, np.array([-0.1, 0, 0])) is None
+
+
+def test_rotate_requires_selected_hand_confirmation_before_motion(tmp_path):
+ backend, env = _official_backend(tmp_path)
+ with pytest.raises(ValueError, match="visual_hand_check"):
+ backend.rotate_wrist(hand="left", angle_deg=5)
+ with pytest.raises(ValueError, match="visual_hand_check"):
+ backend.rotate_wrist(
+ hand="left", angle_deg=5, visual_hand_check=_visual_check("right")
+ )
+ assert not env.actions
+
+
+def test_navigation_brake_propagates_raw_success(tmp_path):
+ backend, env = _official_backend(
+ tmp_path, _FakeOfficialBehaviorEnv(success_on_step=1)
+ )
+ backend._call_actor = lambda _: {
+ "base_position": np.zeros(3),
+ "base_quaternion_xyzw": [0, 0, 0, 1],
+ "robot_aabb": [[-0.1, -0.1, 0], [0.1, 0.1, 0.5]],
+ "obstacles": {},
+ "control_dt": 1 / 60,
+ }
+ result = backend.navigate_to(
+ relative_motion={
+ "kind": "translation",
+ "direction": "forward",
+ "distance_m": 0.001,
+ }
+ )
+ assert len(env.actions) == 1
+ assert result["task_success"] is True
+ assert result["stop_reason"] == "official_task_success"
+
+
+def test_motion_result_refreshes_policy_observation_without_extra_rpc(tmp_path):
+ observation = {
+ "main_images": np.zeros((16, 16, 3), dtype=np.uint8),
+ "states": np.arange(256, dtype=np.float32),
+ }
+
+ class Env:
+ def close_gripper(self, **kwargs):
+ return {
+ "primitive_success": True,
+ "task_success": False,
+ "_observation": observation,
+ "info": {"done": {"success": False}},
+ }
+
+ def current_observation(self):
+ raise AssertionError("post-action frame must come from the executed action")
+
+ primitives = BehaviorPrimitives(
+ env=Env(), task_name="turning_on_radio", output_dir=tmp_path
+ )
+ result = primitives.close(hand="left", visual_hand_check=_visual_check("left"))
+ assert primitives.current_observation is observation
+ assert "_observation" not in result
+ assert result["task_success"] is False
+
+
+def test_robot_config_separates_world_and_self_collision_padding(tmp_path):
+ import yaml
+
+ from robots.behavior.motion import build_robot_config
+
+ names = [f"{h}_arm_joint{i}" for h in ("left", "right") for i in range(1, 8)]
+ urdf = tmp_path / "robot.urdf"
+ urdf.write_text(
+ ''
+ + "".join(f'' for n in names)
+ + ""
+ )
+ source = {
+ "collision_link_names": ["base_link", "left_arm_link1"],
+ "collision_spheres": {},
+ "collision_sphere_buffer": 0.002,
+ "self_collision_buffer": {"base_link": 0.02},
+ "self_collision_ignore": {},
+ "extra_links": {},
+ "extra_collision_spheres": {},
+ "cspace": {
+ "joint_names": names,
+ "cspace_distance_weight": [1] * 14,
+ "null_space_weight": [1] * 14,
+ },
+ }
+ config_file = tmp_path / "collision.yaml"
+ config_file.write_text(yaml.safe_dump({"robot_cfg": {"kinematics": source}}))
+ result = build_robot_config(
+ {
+ "collision_config_path": str(config_file),
+ "urdf_path": str(urdf),
+ "joint_positions": dict.fromkeys(names, 0),
+ }
+ )["kinematics"]
+ assert result["collision_sphere_buffer"] == 0.002
+ assert result["self_collision_buffer"]["base_link"] == pytest.approx(0.018)
+ assert result["self_collision_buffer"]["left_arm_link1"] == -0.002
+ assert result["self_collision_ignore"] == source["self_collision_ignore"]
+
+
+def test_gripper_close_builds_hold_action_and_target_command(tmp_path: Path) -> None:
+ backend, env = _official_backend(tmp_path)
+
+ result = backend.close(hand="left", visual_hand_check=_visual_check("left"))
+ chunk = validate_action_chunk(np.stack(env.actions, axis=0))
+ first = chunk[0]
+
+ assert result["status"] == "ok"
+ assert result["primitive_success"] is True
+ assert result["task_success"] is False
+ assert result["stop_reason"] == "requested_actions_completed"
+ assert result["visual_hand_check"] == _visual_check("left")
+ assert result["visual_hand_check_verification"] == "not_verified"
+ assert result["action_shape"] == [1, 23]
+ assert result["action_chunk_shape"] == [GRIPPER_COMMAND_CONTROL_CYCLES, 23]
+ assert chunk.shape == (GRIPPER_COMMAND_CONTROL_CYCLES, 23)
+ assert np.allclose(chunk, first)
+ assert np.allclose(first[ENV_ACTION_SEGMENTS["base"]], 0.0)
+ assert np.allclose(
+ first[ENV_ACTION_SEGMENTS["trunk"]],
+ env.raw[RAW_PROPRIO_SEGMENTS["trunk"]],
+ )
+ assert np.allclose(
+ first[ENV_ACTION_SEGMENTS["left_arm"]],
+ env.raw[RAW_PROPRIO_SEGMENTS["left_arm"]],
+ )
+ assert np.allclose(
+ first[ENV_ACTION_SEGMENTS["right_arm"]],
+ env.raw[RAW_PROPRIO_SEGMENTS["right_arm"]],
+ )
+ assert first[ENV_ACTION_SEGMENTS["left_gripper"]][0] == GRIPPER_CLOSE_COMMAND
+ assert first[ENV_ACTION_SEGMENTS["right_gripper"]][0] == GRIPPER_OPEN_COMMAND
+
+
+def test_gripper_latch_holds_non_target_hand_and_open_echoes_release_check(
+ tmp_path: Path,
+) -> None:
+ backend, env = _official_backend(tmp_path)
+
+ backend.close(hand="left", visual_hand_check=_visual_check("left"))
+ env.actions.clear()
+ release_check = {
+ "camera": "head",
+ "frame_id": "release-frame",
+ "assessment": "target_visibly_released",
+ }
+ result = backend.open(
+ hand="right",
+ visual_hand_check=_visual_check("right"),
+ release_visual_check=release_check,
+ )
+ first = validate_action_chunk(np.stack(env.actions, axis=0))[0]
+
+ assert result["status"] == "ok"
+ assert result["release_visual_check"] == release_check
+ assert result["release_visual_check_verification"] == "not_verified"
+ assert first[ENV_ACTION_SEGMENTS["left_gripper"]][0] == GRIPPER_CLOSE_COMMAND
+ assert first[ENV_ACTION_SEGMENTS["right_gripper"]][0] == GRIPPER_OPEN_COMMAND
+
+
+def test_gripper_task_success_uses_only_official_done_success(tmp_path: Path) -> None:
+ terminated_env = _FakeOfficialBehaviorEnv(terminated_on_step=1)
+ backend, env = _official_backend(tmp_path / "terminated", terminated_env)
+
+ result = backend.close(hand="left", visual_hand_check=_visual_check("left"))
+
+ assert result["stop_reason"] == "terminated"
+ assert result["primitive_success"] is True
+ assert result["task_success"] is False
+ assert backend.official_success_latched is False
+ assert len(env.actions) == 1
+ rejected = backend.open(hand="left", visual_hand_check=_visual_check("left"))
+ assert rejected["stop_reason"] == "episode_ended"
+ assert len(env.actions) == 1
+
+ success_env = _FakeOfficialBehaviorEnv(success_on_step=1)
+ success_backend, _success_env = _official_backend(tmp_path / "success", success_env)
+ success = success_backend.open(
+ hand="right",
+ visual_hand_check=_visual_check("right"),
+ )
+
+ assert success["stop_reason"] == "official_task_success"
+ assert success["task_success"] is True
+ assert success["official_success_receipt"]["source"] == 'info["done"]["success"]'
+
+
+def test_gripper_execution_error_uses_motion_error_envelope(tmp_path: Path) -> None:
+ backend, env = _official_backend(tmp_path)
+ assert backend._last_obs is not None
+ backend._last_obs["states"][RAW_PROPRIO_SEGMENTS["trunk"]] = np.nan
+
+ result = backend.open(hand="left", visual_hand_check=_visual_check("left"))
+
+ assert result["status"] == "failed"
+ assert result["primitive_success"] is False
+ assert result["task_success"] is False
+ assert result["stop_reason"] == "error"
+ assert "NaN or infinity" in result["error"]
+ assert env.actions == []
+
+
+def test_backend_lifecycle_close_still_closes_env_without_primitive_args(
+ tmp_path: Path,
+) -> None:
+ backend, env = _official_backend(tmp_path)
+
+ assert backend.close() == {"status": "ok", "closed": True}
+ assert env.closed is True
+ assert backend.close() == {
+ "status": "ok",
+ "closed": True,
+ "already_closed": True,
+ }
+
+
+@pytest.mark.parametrize(
+ "ending", ["contact", "terminated", "official_task_success", "duration_limit"]
+)
+def test_press_executes_bounded_hold_actions_and_reports_raw_success(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ending: str
+) -> None:
+ backend, env = _official_backend(
+ tmp_path,
+ _FakeOfficialBehaviorEnv(
+ terminated_on_step=1 if ending == "terminated" else None,
+ success_on_step=1 if ending == "official_task_success" else None,
+ ),
+ )
+
+ def state():
+ return {
+ "control_dt": 0.05,
+ "hands": {
+ "left": {
+ "position": np.array([0.0, 0.0, len(env.actions) * 0.0005]),
+ "approach_direction": np.array([0.0, 0.0, 1.0]),
+ "joint_positions": env.raw[RAW_PROPRIO_SEGMENTS["left_arm"]],
+ "joint_lower_limits": np.full(7, -3.0),
+ "joint_upper_limits": np.full(7, 3.0),
+ "jacobian": np.eye(6, 7),
+ "contacts": ["button"]
+ if ending == "contact" and env.actions
+ else [],
+ }
+ },
+ }
+
+ monkeypatch.setattr(backend, "_get_motion_state", state)
+ hold = backend._hold_action_from_current_proprio()
+ result = backend.press(
+ hand="left", visual_hand_check=_visual_check("left"), duration_s=0.1
+ )
+ assert result["stop_reason"] == ending
+ assert result["task_success"] is (ending == "official_task_success")
+ assert result["primitive_success"] is (
+ ending in {"contact", "official_task_success"}
+ )
+ assert result["executed_steps"] == (2 if ending == "duration_limit" else 1)
+ untouched = np.ones(23, dtype=bool)
+ untouched[ENV_ACTION_SEGMENTS["left_arm"]] = False
+ for action in env.actions:
+ assert validate_action_chunk(action[None, :]).shape == (1, 23)
+ np.testing.assert_array_equal(action[untouched], hold[untouched])
+ assert result["visual_hand_check_verification"] == "not_verified"
+
+
+@pytest.mark.parametrize("duration", [0, -1, 11, float("nan"), float("inf")])
+def test_press_rejects_invalid_duration_before_motion(tmp_path: Path, duration: float):
+ backend, env = _official_backend(tmp_path)
+ with pytest.raises(ValueError, match="duration_s"):
+ backend.press(
+ hand="left", visual_hand_check=_visual_check("left"), duration_s=duration
+ )
+ assert not env.actions
+
+
+@pytest.mark.parametrize(
+ ("extra_args", "message"),
+ [
+ (
+ [
+ "--behavior-mode",
+ "explore",
+ "--explore-attempts-per-session",
+ "1",
+ ],
+ "BEHAVIOR explore runs one attempt per session; use --explore-sessions",
+ ),
+ ([], "BEHAVIOR --explore requires --behavior-mode explore"),
+ (
+ ["--behavior-mode", "explore", "--dashboard"],
+ "BEHAVIOR --explore is CLI-only",
+ ),
+ (
+ ["--behavior-mode", "explore", "--env-endpoint", "127.0.0.1:1"],
+ "BEHAVIOR explore requires an owned env sidecar; omit --env-endpoint",
+ ),
+ ],
+)
+def test_behavior_explore_rejects_incompatible_options(
+ monkeypatch: pytest.MonkeyPatch,
+ capsys: pytest.CaptureFixture[str],
+ extra_args: list[str],
+ message: str,
+) -> None:
+ from rpent.cli import main as cli
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "rpent",
+ "--robot",
+ "behavior",
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "0",
+ "--explore",
+ *extra_args,
+ ],
+ )
+
+ with pytest.raises(SystemExit) as exc_info:
+ cli.main()
+
+ assert exc_info.value.code == 2
+ assert message in capsys.readouterr().err
+
+
+def test_behavior_toolkit_factory_maps_shared_modes(tmp_path: Path) -> None:
+ config = RunConfig(
+ recipe_tag="turning_on_radio_s0",
+ output_dir=tmp_path / "run",
+ prompt_vars={
+ "behavior_mode": "eval",
+ "memory_dir": str(tmp_path / "memory"),
+ "task_name": "turning_on_radio",
+ "public_seed": 0,
+ "max_episode_steps": 64,
+ },
+ task_desc={},
+ )
+ dashboard_events = NullDashboardEventSink()
+
+ evaluation = get_toolkit(
+ primitives_kwargs={},
+ dashboard_events=dashboard_events,
+ config=config,
+ mode="evaluation",
+ )
+ exploration = get_toolkit(
+ primitives_kwargs={"output_dir": tmp_path / "session"},
+ dashboard_events=dashboard_events,
+ config=config,
+ mode="exploration",
+ )
+ config.prompt_vars["behavior_mode"] = "explore"
+ inherited = get_toolkit(
+ primitives_kwargs={},
+ dashboard_events=dashboard_events,
+ config=config,
+ )
+
+ assert evaluation.primitives.behavior_phase == "eval"
+ assert exploration.primitives.behavior_phase == "explore"
+ assert inherited.primitives.behavior_phase == "explore"
+ assert evaluation.memory._memory_access == "read_only"
+ assert exploration.memory._memory_access == "inbox_write"
+ assert exploration.primitives.output_dir == tmp_path / "session"
+ with pytest.raises(ValueError, match="one attempt per session"):
+ get_toolkit(
+ primitives_kwargs={},
+ dashboard_events=dashboard_events,
+ config=config,
+ mode="exploration",
+ attempts_per_session=1,
+ )
+ with pytest.raises(ValueError, match="unsupported BEHAVIOR toolkit mode"):
+ get_toolkit(
+ primitives_kwargs={},
+ dashboard_events=dashboard_events,
+ config=config,
+ mode="unsupported",
+ )
+
+
+def test_behavior_external_sidecar_python_gets_source_checkout(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from robots.behavior import runtime
+
+ monkeypatch.setenv("PYTHONPATH", "/tmp/existing-pythonpath")
+
+ env = runtime._behavior_subprocess_env(
+ cuda_device="2",
+ ROBOT_PLATFORM="BEHAVIOR",
+ )
+
+ assert env["ROBOT_PLATFORM"] == "BEHAVIOR"
+ assert env["CUDA_VISIBLE_DEVICES"] == "2"
+ assert env["PYTHONPATH"].split(os.pathsep)[:2] == [
+ str(runtime.get_repo_root()),
+ "/tmp/existing-pythonpath",
+ ]
+
+
+def test_behavior_facades_use_default_healthz_and_registered_metadata() -> None:
+ facade = BehaviorEnvFacade(backend=object(), meta={"task_language": "test"})
+ dino = BehaviorDinoFacade(
+ encoder=object(),
+ meta={"runtime": "behavior_dino", "dimension": DINOV2_DIMENSION},
+ )
+
+ assert facade._dispatch("healthz", (), {}) == {"status": "ok"}
+ assert facade._dispatch("env.get_env_meta", (), {}) == {"task_language": "test"}
+ assert "env.close_gripper" in facade._rpc
+ assert "env.open_gripper" in facade._rpc
+ assert "env.close" not in facade._rpc
+ assert "env.open" not in facade._rpc
+ with pytest.raises(ValueError, match="requires primitive arguments"):
+ facade.close_gripper()
+ assert dino._dispatch("healthz", (), {}) == {"status": "ok"}
+ dino_meta = dino._dispatch("dino.get_meta", (), {})
+ assert dino_meta["runtime"] == "behavior_dino"
+ assert dino_meta["dimension"] == DINOV2_DIMENSION
+ assert isinstance(dino_meta["pid"], int)
+
+
+def test_behavior_env_facade_serve_dispatches_business_calls_on_serving_thread() -> (
+ None
+):
+ facade = _ThreadRecordingBehaviorEnvFacade()
+ port = pick_free_port()
+ thread = threading.Thread(
+ target=facade.serve,
+ kwargs={
+ "transport": "http",
+ "host": "127.0.0.1",
+ "port": port,
+ },
+ daemon=True,
+ )
+ thread.start()
+ client = HttpRpcClient(f"http://127.0.0.1:{port}")
+
+ try:
+ deadline = time.monotonic() + 3.0
+ while True:
+ try:
+ assert client.call("healthz", timeout_s=0.5) == {"status": "ok"}
+ break
+ except Exception:
+ if time.monotonic() >= deadline:
+ raise
+ time.sleep(0.01)
+
+ assert client.call("env.get_env_meta", timeout_s=1.0) == {
+ "task_language": "test"
+ }
+ assert facade.business_thread_id == facade.serve_thread_id
+ assert facade.business_thread_id != threading.get_ident()
+ assert client.call("shutdown", timeout_s=1.0) == {"ok": True}
+ finally:
+ client.close()
+ facade._shutdown_event.set()
+ thread.join(timeout=3.0)
+
+ assert not thread.is_alive()
+ assert facade._closed
+ assert not _port_accepts_connections(port)
+
+
+def test_env_main_sigterm_uses_owning_thread_cleanup(monkeypatch, tmp_path):
+ from robots.behavior import env_server, rlinf_env
+
+ closed_on = []
+
+ class Backend:
+ def __init__(self, **kwargs):
+ pass
+
+ def close(self):
+ closed_on.append(threading.get_ident())
+
+ def serve(facade, **kwargs):
+ try:
+ signal.raise_signal(signal.SIGTERM)
+ assert facade._shutdown_event.is_set()
+ finally:
+ facade.close()
+
+ previous = signal.getsignal(signal.SIGTERM)
+ monkeypatch.setattr(rlinf_env, "OfficialBehaviorBackend", Backend)
+ monkeypatch.setattr(env_server.BehaviorEnvFacade, "serve", serve)
+ monkeypatch.setattr(env_server, "_build_meta", lambda args: {})
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "env_server",
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "0",
+ "--task-index",
+ "0",
+ "--activity-definition-id",
+ "0",
+ "--activity-instance-id",
+ "242",
+ "--scene-model",
+ "test",
+ "--max-episode-steps",
+ "32",
+ "--output-dir",
+ str(tmp_path),
+ "--behavior-repo",
+ str(tmp_path),
+ "--port",
+ "0",
+ ],
+ )
+ env_server.main()
+ assert closed_on == [threading.get_ident()]
+ assert signal.getsignal(signal.SIGTERM) == previous
+
+
+def test_behavior_clients_and_tools_use_explicit_component_rpc_names() -> None:
+ rpc = _FakeRpcClient()
+ client = BehaviorEnvClient(rpc, expected_meta={})
+ dino_client = BehaviorDinoClient(rpc, expected_meta={"runtime": "behavior_dino"})
+ env = _FakeEnv()
+ primitives = BehaviorPrimitives(env=env, task_name="turning_on_radio")
+
+ client.close_gripper(hand="right")
+ client.open_gripper(hand="right")
+ primitives.close(hand="right")
+ primitives.open(hand="right")
+
+ assert rpc.calls == [
+ ("env.get_env_meta", {}),
+ ("dino.get_meta", {}),
+ ("env.close_gripper", {"hand": "right"}),
+ ("env.open_gripper", {"hand": "right"}),
+ ]
+ assert dino_client.server_meta["dimension"] == DINOV2_DIMENSION
+ assert env.gripper_calls == [
+ ("close_gripper", {"hand": "right"}),
+ ("open_gripper", {"hand": "right"}),
+ ]
+ assert "env.close_gripper" in BehaviorEnvClient._TIMEOUT_S
+ assert "env.open_gripper" in BehaviorEnvClient._TIMEOUT_S
+ assert "env.close" not in BehaviorEnvClient._TIMEOUT_S
+ assert "env.open" not in BehaviorEnvClient._TIMEOUT_S
+
+
+def test_move_to_contract_separates_single_and_dual_hand_branches() -> None:
+ schema = MOVE_TO_SPEC["input_schema"]
+ single, dual = schema["oneOf"]
+ assert schema["properties"]["hand"]["enum"] == ["left", "right", "both"]
+ assert single["properties"]["hand"] == {"enum": ["left", "right"]}
+ assert single["required"] == ["hand", "target"]
+ assert dual["properties"]["hand"] == {"const": "both"}
+ assert dual["required"] == ["hand", "targets", "visual_hand_checks"]
+
+ env = _FakeEnv()
+ primitives = BehaviorPrimitives(env=env, task_name="turning_on_radio")
+ for hand in ("left", "right"):
+ request = {"hand": hand, "target": {"delta_xyz": [0.0, 0.0, 0.01]}}
+ primitives.move_to(**request)
+ assert env.last_move == request
+
+ both = _both_hand_request()
+ primitives.move_to(**both)
+ assert env.last_move == both
+ invalid = _both_hand_request()
+ invalid["targets"] = {"left": invalid["targets"]["left"]}
+ with pytest.raises(ValueError, match="exactly left and right"):
+ primitives.move_to(**invalid)
+
+
+def test_finish_writes_terminal_receipt(tmp_path: Path) -> None:
+ output_dir = tmp_path / "run"
+ toolkit = BehaviorToolkit(
+ primitives_kwargs={
+ "task_name": "turning_on_radio",
+ "output_dir": output_dir,
+ },
+ dashboard_events=NullDashboardEventSink(),
+ memory=MemoryManager(tmp_path / "memory"),
+ )
+
+ result = toolkit.execute_tool(
+ "finish", {"status": "incomplete", "summary": "bounded test"}
+ )
+ receipt = json.loads((output_dir / "terminal_receipt.json").read_text())
+
+ assert result.is_finish is True
+ assert receipt["_finish"] is True
+ assert receipt["kind"] == "behavior_finish_terminal_receipt"
+ assert receipt["planner_status"] == "incomplete"
+ assert receipt["summary"] == "bounded test"
+
+
+def test_receipt_is_session_artifact_and_recipe_is_run_artifact(
+ tmp_path: Path,
+) -> None:
+ run_dir = tmp_path / "run"
+ session_dir = run_dir / "sessions" / "session_001"
+ toolkit = BehaviorToolkit(
+ primitives_kwargs={
+ "task_name": "turning_on_radio",
+ "output_dir": run_dir,
+ },
+ dashboard_events=NullDashboardEventSink(),
+ memory=MemoryManager(tmp_path / "memory"),
+ config=RunConfig(
+ recipe_tag="turning_on_radio_s0",
+ output_dir=run_dir,
+ prompt_vars={
+ "task_name": "turning_on_radio",
+ "public_seed": 0,
+ "memory_dir": str(tmp_path / "memory"),
+ },
+ task_desc={},
+ ),
+ state_output_dir=session_dir,
+ )
+ run_audit = run_dir / "turning_on_radio_s0.json"
+ run_audit.parent.mkdir(parents=True, exist_ok=True)
+ run_audit.write_text('{"audit": true}\n')
+
+ toolkit.execute_tool("finish", {"status": "incomplete", "summary": "done"})
+ assert (session_dir / "terminal_receipt.json").is_file()
+ assert json.loads((session_dir / "states.json").read_text())["run_artifacts"] == [
+ "terminal_receipt.json"
+ ]
+ assert run_audit.read_text() == '{"audit": true}\n'
+
+ toolkit.primitives._official_success_latched = True
+ with toolkit.state.record_step(
+ state={"task_success": True},
+ terminated=True,
+ command={"action": "future_stateful_command", "arg": 1},
+ result={"ok": True},
+ elapsed_s=0.1,
+ ):
+ pass
+ with toolkit.state.record_step(
+ state={"task_success": True},
+ terminated=True,
+ command={"action": "bad_command"},
+ result={"error": "failed"},
+ elapsed_s=0.1,
+ ):
+ pass
+
+ recipe_path = Path(toolkit.write_recipe("turning_on_radio_s0") or "")
+ assert recipe_path == run_dir / "recipe_turning_on_radio_s0.jsonl"
+ assert not (session_dir / "recipe_turning_on_radio_s0.jsonl").exists()
+ commands = [
+ json.loads(line)
+ for line in recipe_path.read_text().splitlines()
+ if line.strip()
+ ]
+ assert commands == [{"action": "future_stateful_command", "arg": 1}]
+ assert "command" not in commands[0]
+
+
+def test_unsolved_behavior_session_does_not_write_recipe(tmp_path: Path) -> None:
+ toolkit = BehaviorToolkit(
+ primitives_kwargs={
+ "task_name": "turning_on_radio",
+ "output_dir": tmp_path / "run",
+ },
+ dashboard_events=NullDashboardEventSink(),
+ memory=MemoryManager(tmp_path / "memory"),
+ )
+ with toolkit.state.record_step(
+ state={"task_success": False},
+ command={"action": "pi0_nav_pick", "instruction": "turn on the radio"},
+ result={"ok": True},
+ elapsed_s=0.1,
+ ):
+ pass
+
+ assert toolkit.write_recipe("turning_on_radio_s0") is None
+ assert not (tmp_path / "run" / "recipe_turning_on_radio_s0.jsonl").exists()
+
+
+def test_behavior_pi0_chunk_records_streaming_episode_video(tmp_path: Path) -> None:
+ run_dir = tmp_path / "run"
+ session_dir = run_dir / "sessions" / "session_001"
+ toolkit = BehaviorToolkit(
+ primitives_kwargs={
+ "env": _FakeChunkEnv(),
+ "model": _FakeModel(),
+ "task_name": "turning_on_radio",
+ "public_seed": 0,
+ "max_episode_steps": 64,
+ "initial_observation": {
+ "main_images": np.zeros((16, 16, 3), dtype=np.uint8),
+ "task_descriptions": "turn on the radio",
+ },
+ },
+ dashboard_events=NullDashboardEventSink(),
+ memory=MemoryManager(tmp_path / "memory"),
+ config=RunConfig(
+ recipe_tag="turning_on_radio_s0",
+ output_dir=run_dir,
+ prompt_vars={
+ "behavior_mode": "explore",
+ "task_name": "turning_on_radio",
+ "public_seed": 0,
+ "memory_dir": str(tmp_path / "memory"),
+ },
+ task_desc={},
+ ),
+ state_output_dir=session_dir,
+ )
+
+ result = toolkit.primitives.pi0_nav_pick(
+ instruction="turn on the radio",
+ chunks=1,
+ )
+ toolkit.execute_tool("finish", {"status": "incomplete", "summary": "done"})
+
+ assert result["chunks_used"] == 1
+ assert result["env_steps_used"] == 32
+ assert toolkit.primitives.env.return_all_frames == [True]
+ video_path = session_dir / "episode.mp4"
+ assert video_path.is_file()
+ assert video_path.stat().st_size > 0
+ assert (
+ "episode.mp4"
+ in json.loads((session_dir / "states.json").read_text())["run_artifacts"]
+ )
+
+
+def test_behavior_build_memory_cli_wraps_existing_catalog_compiler(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+) -> None:
+ captured: dict[str, Any] = {}
+
+ def fake_compile_runtime_catalog(**kwargs: Any) -> dict[str, Any]:
+ captured.update(kwargs)
+ return {"artifact_dir": str(kwargs["output_dir"]), "preliminary": True}
+
+ monkeypatch.setenv("CUDA_VISIBLE_DEVICES", "7")
+ monkeypatch.setattr(
+ build_memory_cli,
+ "compile_runtime_catalog",
+ fake_compile_runtime_catalog,
+ )
+
+ rc = build_memory_cli.main(
+ [
+ "--selection-manifest",
+ str(tmp_path / "selection.json"),
+ "--output-dir",
+ str(tmp_path / "catalog"),
+ "--video-root",
+ str(tmp_path / "videos"),
+ "--rollups-dir",
+ str(tmp_path / "rollups"),
+ "--source-archive",
+ str(tmp_path / "dinov2.tar.gz"),
+ "--weights",
+ str(tmp_path / "dinov2.pth"),
+ "--cache-dir",
+ str(tmp_path / "cache"),
+ "--cuda-device",
+ "2",
+ "--batch-size",
+ "8",
+ ]
+ )
+
+ assert rc == 0
+ assert json.loads(capsys.readouterr().out) == {
+ "artifact_dir": str((tmp_path / "catalog").resolve()),
+ "preliminary": True,
+ }
+ assert captured["selection_manifest"] == (tmp_path / "selection.json").resolve()
+ assert captured["output_dir"] == (tmp_path / "catalog").resolve()
+ assert captured["video_roots"] == ((tmp_path / "videos").resolve(),)
+ assert captured["rollups_dir"] == (tmp_path / "rollups").resolve()
+ assert captured["source_archive"] == (tmp_path / "dinov2.tar.gz").resolve()
+ assert captured["weights"] == (tmp_path / "dinov2.pth").resolve()
+ assert captured["cache_dir"] == (tmp_path / "cache").resolve()
+ assert captured["batch_size"] == 8
+ assert os.environ["CUDA_VISIBLE_DEVICES"] == "2"
+
+
+def test_behavior_build_memory_cli_rejects_multi_cuda_device() -> None:
+ with pytest.raises(SystemExit) as exc_info:
+ build_memory_cli.main(
+ [
+ "--selection-manifest",
+ "selection.json",
+ "--output-dir",
+ "catalog",
+ "--video-root",
+ "videos",
+ "--rollups-dir",
+ "rollups",
+ "--source-archive",
+ "dinov2.tar.gz",
+ "--weights",
+ "dinov2.pth",
+ "--cuda-device",
+ "2,7",
+ ]
+ )
+ assert exc_info.value.code == 2
diff --git a/tests/unit_tests/robots/test_toolkit_contracts.py b/tests/unit_tests/robots/test_toolkit_contracts.py
index 1656e1f1b..f96221e73 100644
--- a/tests/unit_tests/robots/test_toolkit_contracts.py
+++ b/tests/unit_tests/robots/test_toolkit_contracts.py
@@ -20,6 +20,8 @@
import pytest
+from robots.behavior import robot_spec as behavior_robot_spec
+from robots.behavior import toolkit as behavior_toolkit
from robots.libero import robot_spec as libero_robot_spec
from robots.libero import toolkit as libero_toolkit
from robots.robocasa import robot_spec as robocasa_robot_spec
@@ -120,3 +122,56 @@ def test_toolkit_factories_fall_back_to_each_robot_memory_root(
)
assert toolkit.memory.root == default_memory.resolve()
+
+
+@pytest.mark.parametrize(
+ ("mode", "write_allowed"),
+ [("eval", False), ("explore", True)],
+)
+def test_behavior_toolkit_uses_one_official_memory_manager(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ mode: str,
+ write_allowed: bool,
+) -> None:
+ captured: dict[str, Any] = {}
+
+ def fake_toolkit(**kwargs: Any) -> SimpleNamespace:
+ captured.update(kwargs)
+ return SimpleNamespace(**kwargs)
+
+ monkeypatch.setattr(behavior_toolkit, "BehaviorToolkit", fake_toolkit)
+ memory_dir = tmp_path / "memory"
+ recipe_tag = "turning_on_radio_s1"
+ config = RunConfig(
+ recipe_tag=recipe_tag,
+ output_dir=tmp_path / "run",
+ prompt_vars={
+ "behavior_mode": mode,
+ "memory_dir": str(memory_dir),
+ },
+ task_desc={},
+ )
+
+ toolkit = behavior_robot_spec.get_toolkit(
+ primitives_kwargs={},
+ dashboard_events=NullDashboardEventSink(),
+ config=config,
+ )
+
+ assert toolkit.memory is captured["memory"]
+ assert toolkit.memory.root == memory_dir.resolve()
+ write = toolkit.memory.get_common_tool_bindings()["write_text_file"][1]
+ destination = memory_dir / "_internal" / "inbox" / recipe_tag / "wip" / "notes.md"
+ if write_allowed:
+ write(str(destination), "evidence")
+ assert destination.read_text() == "evidence"
+ else:
+ with pytest.raises(PermissionError, match="writing to memory is denied"):
+ write(str(destination), "evidence")
+
+ component_names = {
+ item["name"]
+ for item in behavior_robot_spec.BEHAVIOR_DASHBOARD_SPEC["runtime_components"]
+ }
+ assert component_names == {"env", "vla", "dino"}
diff --git a/tests/unit_tests/rpent/cli/test_main_contracts.py b/tests/unit_tests/rpent/cli/test_main_contracts.py
index abf1a1a75..3185167d0 100644
--- a/tests/unit_tests/rpent/cli/test_main_contracts.py
+++ b/tests/unit_tests/rpent/cli/test_main_contracts.py
@@ -184,7 +184,7 @@ def test_robot_and_env_aliases_are_mutually_exclusive(
["--robot", "libero", "--dashboard", "--interactive"],
"cannot be used together",
),
- (["--robot", "robocasa", "--explore"], "supported only for LIBERO"),
+ (["--robot", "robocasa", "--explore"], "--explore is not supported"),
(
["--robot", "libero", "--explore", "--memory-profile", "hf"],
"cannot be used with --memory-profile hf",
@@ -473,6 +473,8 @@ def reject_memory_sync(*args: Any, **kwargs: Any) -> None:
assert calls["get_toolkit"][1]["primitives_kwargs"] == {"runtime": "simulated"}
assert calls["get_toolkit"][1]["mode"] == "exploration"
assert calls["get_toolkit"][1]["attempts_per_session"] == 2
+ assert robot_spec.on_explore_session is None
+ assert calls["init_runtime"][3] is None
assert calls["write_recipe"] == "libero_s0"
assert calls["merge_memory"] == {
"cell_tag": "libero_s0",
@@ -622,3 +624,127 @@ def finalize_run(context: RunFinalizationContext) -> Path:
}
assert robot_toolkit.closed is True
assert daemon.stopped is True
+
+
+def test_behavior_explore_hook_restarts_only_env_between_cli_sessions(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+) -> None:
+ from dataclasses import replace
+
+ from robots.behavior import runtime
+ from robots.behavior.robot_spec import get_robot_spec
+ from rpent.planner.base import PlannerResult
+ from rpent.robots.prompt_bundle import PromptBundle
+
+ cli = _cli_module()
+ spawned = []
+ stopped = []
+ toolkit_calls = []
+ runtime_calls = []
+ merge_calls = []
+ solve_calls = []
+
+ class FakeDaemon:
+ def __init__(self, component):
+ self.name = f"behavior_{component}_server"
+ self.component = component
+
+ def stop(self):
+ stopped.append(self)
+
+ def spawn(owned, events, component, spawn_fn):
+ daemon = FakeDaemon(component)
+ owned[component] = daemon
+ spawned.append(daemon)
+ return daemon, daemon
+
+ def wait(owned, events, component, rpc, daemon, timeout_s, **kwargs):
+ return {component: daemon}
+
+ original_init_runtime = runtime.init_runtime
+
+ def init_runtime(args, output_dir, events, components):
+ runtime_calls.append((output_dir, components))
+ return original_init_runtime(args, output_dir, events, components)
+
+ def get_toolkit(*args, **kwargs):
+ toolkit_calls.append(
+ {**kwargs, "primitives_kwargs": dict(kwargs["primitives_kwargs"])}
+ )
+ return SimpleNamespace(
+ memory=SimpleNamespace(merge_memory=lambda **kw: merge_calls.append(kw)),
+ solved=lambda: False,
+ close=lambda: None,
+ )
+
+ def solve(**kwargs):
+ solve_calls.append(kwargs)
+ assert spawned[-1] not in stopped
+ if len(solve_calls) == 2:
+ assert spawned[2] in stopped
+ return PlannerResult(
+ finish_result={"status": "incomplete"}, messages=[], stats={}
+ )
+
+ monkeypatch.setattr(runtime, "try_spawn_server", spawn)
+ monkeypatch.setattr(runtime, "try_wait_server", wait)
+ monkeypatch.setattr(runtime, "init_runtime", init_runtime)
+ spec = replace(
+ get_robot_spec(),
+ prompts=PromptBundle(
+ system=lambda variables: "system", user=lambda variables: "user"
+ ),
+ )
+ monkeypatch.setattr(cli, "get_robot_spec", lambda name: spec)
+ monkeypatch.setattr(cli, "get_toolkit", get_toolkit)
+ monkeypatch.setattr(
+ cli, "build_planner", lambda *args, **kwargs: SimpleNamespace(solve=solve)
+ )
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "rpent",
+ "--robot",
+ "behavior",
+ "--behavior-mode",
+ "explore",
+ "--explore",
+ "--explore-sessions",
+ "2",
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "0",
+ "--output-dir",
+ str(tmp_path),
+ "--memory-dir",
+ str(tmp_path / "memory"),
+ ],
+ )
+
+ assert cli.main() == 0
+ session_dirs = [tmp_path / "sessions" / f"session_{n:03d}" for n in (1, 2)]
+ assert runtime_calls == [
+ (tmp_path, None),
+ *[(path, {"env"}) for path in session_dirs],
+ ]
+ assert [daemon.component for daemon in spawned] == ["vla", "dino", "env", "env"]
+ assert len(toolkit_calls) == len(solve_calls) == 2
+ for index, call in enumerate(toolkit_calls):
+ assert call["state_output_dir"] == session_dirs[index]
+ assert call["mode"] == "exploration"
+ assert call["attempts_per_session"] == 0
+ assert call["primitives_kwargs"] == {
+ "vla": spawned[0],
+ "dino": spawned[1],
+ "env": spawned[index + 2],
+ }
+ assert stopped == [spawned[2], spawned[0], spawned[1], spawned[3]]
+ assert merge_calls == [
+ {
+ "cell_tag": "turning_on_radio_s0",
+ "run_state_dir": tmp_path,
+ "solved": False,
+ }
+ ]
diff --git a/tests/unit_tests/rpent/dashboard/test_session_contracts.py b/tests/unit_tests/rpent/dashboard/test_session_contracts.py
index 6f3a78d33..b61b663b5 100644
--- a/tests/unit_tests/rpent/dashboard/test_session_contracts.py
+++ b/tests/unit_tests/rpent/dashboard/test_session_contracts.py
@@ -20,6 +20,7 @@
import pytest
+from rpent.dashboard import session as dashboard_session
from rpent.dashboard.session import DashboardSessionController
from rpent.dashboard.state import ClaimedTask
from rpent.planner.base import PlannerResult
@@ -152,8 +153,14 @@ def run_task(task: ClaimedTask, shared_kwargs: dict[str, Any]):
def test_dashboard_session_stops_shared_daemons_in_reverse_after_cleanup_error(
- caplog: pytest.LogCaptureFixture,
+ monkeypatch: pytest.MonkeyPatch,
) -> None:
+ warnings: list[str] = []
+
+ def fake_warning(message: str, *args: Any) -> None:
+ warnings.append(message % args if args else message)
+
+ monkeypatch.setattr(dashboard_session.logger, "warning", fake_warning)
stopped: list[str] = []
daemons = [
FakeDaemon("first", stopped),
@@ -170,18 +177,28 @@ def test_dashboard_session_stops_shared_daemons_in_reverse_after_cleanup_error(
controller.run()
assert stopped == ["third", "second", "first"]
- assert "shared runtime cleanup failed: stop failed" in caplog.text
+ assert warnings == ["shared runtime cleanup failed: stop failed"]
+@pytest.mark.parametrize("robot_name", ["libero", "behavior"])
@pytest.mark.parametrize("merge_fails", [False, True])
+@pytest.mark.parametrize("auto_merge", [False, True])
+@pytest.mark.parametrize("solved", [False, True])
+@pytest.mark.parametrize("mode_explore", [False, True])
def test_dashboard_exploration_finalizes_memory_and_reports_merge_failures(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
+ robot_name: str,
merge_fails: bool,
+ auto_merge: bool,
+ solved: bool,
+ mode_explore: bool,
) -> None:
from rpent.cli import dashboard as dashboard_cli
merge_calls: list[dict[str, Any]] = []
+ toolkit_calls: list[dict[str, Any]] = []
+ recipe_calls: list[str] = []
class FakeMemoryManager:
def merge_memory(self, **kwargs: Any) -> dict[str, int]:
@@ -194,10 +211,11 @@ class FakeToolkit:
memory = FakeMemoryManager()
def solved(self) -> bool:
- return True
+ return solved
def write_recipe(self, recipe_tag: str) -> str:
- return str(tmp_path / f"{recipe_tag}_recipe.jsonl")
+ recipe_calls.append(recipe_tag)
+ return str(tmp_path / f"recipe_{recipe_tag}.jsonl")
def close(self) -> None:
pass
@@ -221,17 +239,17 @@ class FakePlanner:
def solve(self, **kwargs: Any) -> PlannerResult:
del kwargs
return PlannerResult(
- finish_result={"status": "success"},
+ finish_result={"status": "success" if solved else "failure"},
messages=[],
stats={},
)
output_dir = tmp_path / "task"
run_config = RunConfig(
- recipe_tag="libero_s0",
+ recipe_tag=f"{robot_name}_s0",
output_dir=output_dir,
prompt_vars={},
- task_desc={"robot": "libero"},
+ task_desc={"robot": robot_name},
)
robot_spec = SimpleNamespace(
parse_config=lambda args: run_config,
@@ -243,11 +261,12 @@ def solve(self, **kwargs: Any) -> PlannerResult:
)
args = SimpleNamespace(
verbose=False,
- robot_name="libero",
- explore=True,
- auto_merge_memory=True,
+ robot_name=robot_name,
+ explore=robot_name == "libero" and mode_explore,
+ behavior_mode="explore" if robot_name == "libero" or mode_explore else "eval",
+ auto_merge_memory=auto_merge,
explore_sessions=1,
- explore_attempts_per_session=2,
+ explore_attempts_per_session=0,
planner="api",
base_url=None,
model="offline",
@@ -260,9 +279,12 @@ def solve(self, **kwargs: Any) -> PlannerResult:
)
claimed = ClaimedTask(number=1, request={}, output_dir=output_dir)
state = FakeState()
- monkeypatch.setattr(
- dashboard_cli, "get_toolkit", lambda *args, **kwargs: FakeToolkit()
- )
+
+ def fake_get_toolkit(*args: Any, **kwargs: Any) -> FakeToolkit:
+ toolkit_calls.append({"args": args, "kwargs": kwargs})
+ return FakeToolkit()
+
+ monkeypatch.setattr(dashboard_cli, "get_toolkit", fake_get_toolkit)
monkeypatch.setattr(
dashboard_cli, "build_planner", lambda *args, **kwargs: FakePlanner()
)
@@ -278,18 +300,32 @@ def solve(self, **kwargs: Any) -> PlannerResult:
)
assert error is None
- assert merge_calls == [
- {
- "cell_tag": "libero_s0",
- "run_state_dir": output_dir,
- "solved": True,
- }
- ]
- if merge_fails:
+ assert recipe_calls == ([f"{robot_name}_s0"] if solved else [])
+ assert toolkit_calls[0]["kwargs"]["mode"] == (
+ "exploration" if mode_explore else "evaluation"
+ )
+ assert toolkit_calls[0]["kwargs"]["state_output_dir"] == (
+ output_dir / "sessions" / "session_001"
+ if robot_name == "libero" and mode_explore
+ else output_dir
+ )
+ assert merge_calls == (
+ [
+ {
+ "cell_tag": f"{robot_name}_s0",
+ "run_state_dir": output_dir,
+ "solved": solved,
+ }
+ ]
+ if auto_merge and mode_explore
+ else []
+ )
+ if merge_fails and auto_merge and mode_explore:
assert len(state.warnings) == 1
assert (
"memory finalization failed: RuntimeError: merge exploded"
in state.warnings[0]
)
+ assert "succeeded" not in state.warnings[0].lower()
else:
assert state.warnings == []
diff --git a/tests/unit_tests/rpent/dashboard/test_state_contracts.py b/tests/unit_tests/rpent/dashboard/test_state_contracts.py
index d0bfe7bfa..5e6dd0a2a 100644
--- a/tests/unit_tests/rpent/dashboard/test_state_contracts.py
+++ b/tests/unit_tests/rpent/dashboard/test_state_contracts.py
@@ -269,6 +269,7 @@ def test_dashboard_events_project_runtime_usage_timeline_and_newest_frames(
"elapsed_s": 0.5,
"terminated": True,
"truncated": False,
+ "official_success": True,
"action_video_path": None,
"action_video_artifact": None,
"has_action_video": False,
@@ -332,5 +333,144 @@ def test_dashboard_step_events_offset_new_traces_and_resolve_action_video(
assert [item["step"] for item in detail["timeline"]] == [0, 1]
assert detail["timeline"][0]["result"] == {"position": [1, 2, 3]}
assert detail["timeline"][1]["terminated"] is True
+ assert detail["timeline"][1]["official_success"] is True
assert state.frame("camera") == b"second-frame"
assert state.action_video_path(0) == first_env.artifact_path("action.mp4", step=0)
+
+
+def test_dashboard_maps_declared_result_keys_to_physical_frames(
+ tmp_path: Path,
+) -> None:
+ spec = {
+ **DASHBOARD_SPEC,
+ "frame_channels": (
+ {"name": "head", "label": "head", "result_key": "_image_bytes"},
+ {
+ "name": "left_wrist",
+ "label": "left wrist",
+ "result_key": "_image_left_wrist_bytes",
+ },
+ {
+ "name": "right_wrist",
+ "label": "right wrist",
+ "result_key": "_image_right_wrist_bytes",
+ },
+ ),
+ }
+ state = DashboardState(
+ run_id="three-camera",
+ output_dir=tmp_path,
+ dashboard_spec=spec,
+ )
+
+ state.emit(
+ ToolResultEvent(
+ "observe",
+ {
+ "step": 4,
+ "_image_bytes": b"head",
+ "_image_left_wrist_bytes": b"left",
+ "_image_right_wrist_bytes": b"right",
+ },
+ )
+ )
+
+ snapshot = state.snapshot()
+ assert snapshot["frame_available"] == {
+ "head": True,
+ "left_wrist": True,
+ "right_wrist": True,
+ }
+ assert state.frame("head") == b"head"
+ assert state.frame("left_wrist") == b"left"
+ assert state.frame("right_wrist") == b"right"
+
+
+def test_dashboard_legacy_frame_key_fallbacks_remain_supported(
+ tmp_path: Path,
+) -> None:
+ spec = {
+ **DASHBOARD_SPEC,
+ "frame_channels": (
+ {"name": "head", "label": "head"},
+ {"name": "left_wrist", "label": "left wrist"},
+ {"name": "right_wrist", "label": "right wrist"},
+ {"name": "camera", "label": "camera"},
+ {"name": "wrist", "label": "wrist"},
+ ),
+ }
+ state = DashboardState(
+ run_id="legacy-fallbacks",
+ output_dir=tmp_path,
+ dashboard_spec=spec,
+ )
+
+ state.emit(
+ ToolResultEvent(
+ "view_env_state",
+ {
+ "step": 1,
+ "_image_bytes": b"head",
+ "_image_cam_bytes": b"left-and-camera",
+ "_image_wrist_bytes": b"right-and-wrist",
+ },
+ )
+ )
+
+ assert state.frame("head") == b"head"
+ assert state.frame("left_wrist") == b"left-and-camera"
+ assert state.frame("right_wrist") == b"right-and-wrist"
+ assert state.frame("camera") == b"left-and-camera"
+ assert state.frame("wrist") == b"right-and-wrist"
+
+
+def test_behavior_official_success_fields_gate_success_projection(
+ tmp_path: Path,
+) -> None:
+ state = _ready_state(tmp_path)
+ _claim_started_task(state)
+ state.emit(
+ ToolResultEvent(
+ "finish",
+ {
+ "step": 1,
+ "terminated": True,
+ "task_success": False,
+ "official_success_source": 'info["done"]["success"]',
+ "official_success_receipt": None,
+ "log": {
+ "command": {"action": "finish"},
+ "result": {"task_success": False},
+ },
+ },
+ )
+ )
+ detail = state.run_detail()
+ assert detail["terminated"] is True
+ assert detail["official_success"] is False
+ assert detail["timeline"][0]["official_success"] is False
+
+ state = _ready_state(tmp_path / "success")
+ _claim_started_task(state)
+ state.emit(
+ ToolResultEvent(
+ "finish",
+ {
+ "step": 1,
+ "terminated": True,
+ "task_success": True,
+ "official_success_source": 'info["done"]["success"]',
+ "official_success_receipt": {
+ "source": 'info["done"]["success"]',
+ "raw_done": {"success": True},
+ },
+ "log": {
+ "command": {"action": "finish"},
+ "result": {"task_success": True},
+ },
+ },
+ )
+ )
+ detail = state.run_detail()
+ assert detail["official_success"] is True
+ assert detail["timeline"][0]["official_success"] is True
diff --git a/tests/unit_tests/rpent/memory/test_manager_contracts.py b/tests/unit_tests/rpent/memory/test_manager_contracts.py
index 09f33c807..672abfc83 100644
--- a/tests/unit_tests/rpent/memory/test_manager_contracts.py
+++ b/tests/unit_tests/rpent/memory/test_manager_contracts.py
@@ -18,6 +18,7 @@
from pathlib import Path
from typing import Any
+import pytest
import yaml
from rpent.memory import MemoryManager
@@ -211,6 +212,51 @@ def test_memory_manager_does_not_publish_unsolved_task_artifacts(
assert not (memory_dir / "task_only" / f"{cell}_recipe.jsonl").exists()
+@pytest.mark.parametrize("solved", [False, True])
+def test_memory_manager_publishes_generic_command_recipe_only_when_solved(
+ tmp_path: Path, solved: bool
+) -> None:
+ from rpent.session import EnvState, write_command_recipe_from_states
+
+ memory_dir = tmp_path / "memory"
+ output_dir = tmp_path / "run"
+ cell = "turning_on_radio_s0"
+ output_dir.mkdir()
+ audit = output_dir / f"{cell}.json"
+ audit.write_text(json.dumps({"raw_done": {"success": solved}}))
+ # Exercise the real shared writer, not a duplicated filename convention.
+ state = EnvState(output_dir)
+ with state.record_step(
+ state={}, command={"action": "press", "hand": "left"}, result={}
+ ):
+ pass
+ recipe = Path(write_command_recipe_from_states(state, cell))
+ result = MemoryManager(memory_dir).merge_memory(
+ cell_tag=cell, run_state_dir=output_dir, solved=solved
+ )
+ assert result["task"] == int(solved)
+ target = memory_dir / "task_only" / f"{cell}_recipe.jsonl"
+ assert target.exists() is solved
+ if solved:
+ assert target.read_bytes() == recipe.read_bytes()
+ assert (target.parent / audit.name).read_bytes() == audit.read_bytes()
+
+
+def test_memory_manager_prefers_existing_sibling_recipe_name(tmp_path: Path) -> None:
+ memory_dir, output_dir = tmp_path / "memory", tmp_path / "run"
+ cell = "10_task_t2_s0"
+ _write_task_pair(output_dir, cell, solved=True)
+ canonical = output_dir / f"{cell}_recipe.jsonl"
+ (output_dir / f"recipe_{cell}.jsonl").write_text('{"action":"wrong_source"}\n')
+ result = MemoryManager(memory_dir).merge_memory(
+ cell_tag=cell, run_state_dir=output_dir, solved=True
+ )
+ assert result["task"] == 1
+ assert (
+ memory_dir / "task_only" / canonical.name
+ ).read_bytes() == canonical.read_bytes()
+
+
def test_memory_manager_skips_invalid_draft_without_archiving_its_inbox(
tmp_path: Path,
) -> None:
diff --git a/tests/unit_tests/rpent/planner/test_codex_contracts.py b/tests/unit_tests/rpent/planner/test_codex_contracts.py
index 3f8a189b3..011c93a89 100644
--- a/tests/unit_tests/rpent/planner/test_codex_contracts.py
+++ b/tests/unit_tests/rpent/planner/test_codex_contracts.py
@@ -383,6 +383,132 @@ def test_successful_fake_codex_lifecycle_uses_fake_mcp_and_accounts_events(
assert any(isinstance(event, UsageEvent) for event in sink.events)
+def test_finish_downgrades_recorder_only_stream_error(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_fake_backend(monkeypatch)
+ FakeCodex.events = [
+ {
+ "method": "error",
+ "payload": {
+ "error": {
+ "message": "Reconnecting... 2/5",
+ "codex_error_info": {"response_stream_disconnected": {}},
+ },
+ "will_retry": True,
+ },
+ },
+ {
+ "method": "item/completed",
+ "payload": {
+ "item": {
+ "type": "mcpToolCall",
+ "tool": "mcp__rpent__finish",
+ "status": "completed",
+ "arguments": {"status": "success", "summary": "done"},
+ "result": "accepted",
+ }
+ },
+ },
+ {
+ "method": "turn/completed",
+ "payload": {"turn": {"status": "completed", "duration_ms": 1000}},
+ },
+ ]
+
+ result = make_planner(tmp_path, RecordingSink()).solve(
+ system_prompt="",
+ user_message="task",
+ toolkit=FakeToolkit(),
+ max_turns=3,
+ )
+
+ assert result.finish_result == {
+ "_finish": True,
+ "status": "success",
+ "summary": "done",
+ }
+ assert result.error is None
+ assert result.stats["nonfatal_recorder_errors"] == 1
+
+
+def test_recorder_stream_error_remains_fatal_without_finish(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_fake_backend(monkeypatch)
+ FakeCodex.events = [
+ {
+ "method": "error",
+ "payload": {
+ "error": {
+ "message": "Reconnecting... 2/5",
+ "codex_error_info": {"response_stream_disconnected": {}},
+ },
+ "will_retry": True,
+ },
+ },
+ {
+ "method": "turn/completed",
+ "payload": {"turn": {"status": "completed", "duration_ms": 1000}},
+ },
+ ]
+
+ result = make_planner(tmp_path, RecordingSink()).solve(
+ system_prompt="",
+ user_message="task",
+ toolkit=FakeToolkit(),
+ max_turns=3,
+ )
+
+ assert result.finish_result is None
+ assert result.error is not None
+ assert "Reconnecting... 2/5" in result.error
+ assert result.stats["nonfatal_recorder_errors"] == 0
+
+
+def test_failed_turn_error_is_not_suppressed_after_finish(
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ install_fake_backend(monkeypatch)
+ FakeCodex.events = [
+ {
+ "method": "item/completed",
+ "payload": {
+ "item": {
+ "type": "mcpToolCall",
+ "tool": "mcp__rpent__finish",
+ "status": "completed",
+ "arguments": {"status": "success", "summary": "done"},
+ "result": "accepted",
+ }
+ },
+ },
+ {
+ "method": "turn/completed",
+ "payload": {
+ "turn": {
+ "status": "failed",
+ "error": {"message": "Codex turn failed"},
+ }
+ },
+ },
+ ]
+
+ result = make_planner(tmp_path, RecordingSink()).solve(
+ system_prompt="",
+ user_message="task",
+ toolkit=FakeToolkit(),
+ max_turns=3,
+ )
+
+ assert result.finish_result is not None
+ assert result.error == "Codex turn failed"
+ assert result.stats["nonfatal_recorder_errors"] == 0
+
+
def test_rejected_finish_item_is_not_promoted() -> None:
from rpent.planner.codex import _Recorder
diff --git a/tests/unit_tests/rpent/robots/test_config_contracts.py b/tests/unit_tests/rpent/robots/test_config_contracts.py
index e7e9656e0..4dee19c08 100644
--- a/tests/unit_tests/rpent/robots/test_config_contracts.py
+++ b/tests/unit_tests/rpent/robots/test_config_contracts.py
@@ -36,6 +36,11 @@ def _parser(robot_name: str, *, dashboard: bool = False) -> argparse.ArgumentPar
@pytest.mark.parametrize(
("robot_name", "required_args", "identity_fields"),
[
+ (
+ "behavior",
+ ["--task-name", "turning_on_radio", "--public-seed", "1"],
+ ("task_name", "public_seed"),
+ ),
("libero", ["--suite", "libero_object_task", "--task", "2"], ("suite", "task")),
("robocasa", ["--task-name", "OpenDrawer"], ("task_name",)),
(
@@ -62,6 +67,7 @@ def test_robot_arguments_are_required_on_cli_but_deferred_for_dashboard(
@pytest.mark.parametrize(
("robot_name", "message"),
[
+ ("behavior", "--task-name is required"),
("libero", "--suite is required"),
("robocasa", "--task-name is required"),
("robotwin", "--task-name is required"),
@@ -106,6 +112,45 @@ def test_libero_default_evaluation_config(tmp_path: Path) -> None:
}
+def test_behavior_uses_only_the_official_local_memory_profile(tmp_path: Path) -> None:
+ memory_dir = tmp_path / "behavior-memory"
+ args = _parser("behavior").parse_args(
+ [
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "1",
+ "--memory-dir",
+ str(memory_dir),
+ "--output-dir",
+ str(tmp_path / "output"),
+ ]
+ )
+
+ config = get_robot_spec("behavior").parse_config(args)
+
+ assert args.memory_profile == "local"
+ assert args.memory_dir == str(memory_dir.resolve())
+ assert config.prompt_vars["memory_profile"] == "local"
+ assert config.prompt_vars["memory_dir"] == str(memory_dir.resolve())
+ assert config.prompt_vars["memory_inbox"].endswith(
+ "_internal/inbox/turning_on_radio_s1"
+ )
+
+ invalid = _parser("behavior").parse_args(
+ [
+ "--task-name",
+ "turning_on_radio",
+ "--public-seed",
+ "1",
+ "--memory-profile",
+ "hf",
+ ]
+ )
+ with pytest.raises(ValueError, match="requires --memory-profile local"):
+ get_robot_spec("behavior").parse_config(invalid)
+
+
def test_libero_exploration_uses_local_memory_and_session_metadata(
tmp_path: Path,
) -> None:
diff --git a/tests/unit_tests/rpent/robots/test_registry_contracts.py b/tests/unit_tests/rpent/robots/test_registry_contracts.py
index 8aa6d8714..95b305213 100644
--- a/tests/unit_tests/rpent/robots/test_registry_contracts.py
+++ b/tests/unit_tests/rpent/robots/test_registry_contracts.py
@@ -14,10 +14,12 @@
from __future__ import annotations
+import argparse
from dataclasses import FrozenInstanceError
from pathlib import Path
from string import Formatter
+import numpy as np
import pytest
from robots.robotwin.robot_spec import (
@@ -29,9 +31,25 @@
from rpent.robots import enumerate_robots, get_robot_spec
from rpent.robots.robot_spec import RobotSpec, RunConfig
-EXPECTED_ROBOTS = ("libero", "robocasa", "robotwin")
+EXPECTED_ROBOTS = ("behavior", "libero", "robocasa", "robotwin")
PROMPT_VARIABLES = {
+ "behavior": {
+ "behavior_mode": "eval",
+ "task_name": "turning_on_radio",
+ "task": 0,
+ "task_language": "Turn on the radio.",
+ "task_instruction": "Turn on the radio.",
+ "public_seed": 1,
+ "recipe_tag": "turning_on_radio_s1",
+ "output_dir": Path("/output"),
+ "max_episode_steps": 43200,
+ "wall_clock_seconds": 7200,
+ "public_capabilities": ["observe", "pi0_nav_pick", "finish"],
+ "memory_dir": "/memory",
+ "memory_profile": "local",
+ "memory_inbox": "/memory/_inbox/turning_on_radio_s1",
+ },
"libero": {
"suite": "libero_object_task",
"task": 2,
@@ -83,6 +101,10 @@ def test_registry_discovers_exactly_the_source_checkout_robots() -> None:
assert callable(spec.add_cli_args)
assert callable(spec.parse_config)
assert callable(spec.init_runtime)
+ if name == "behavior":
+ assert callable(spec.on_explore_session)
+ else:
+ assert spec.on_explore_session is None
@pytest.mark.parametrize("robot_name", EXPECTED_ROBOTS)
@@ -178,3 +200,207 @@ def test_robotwin_runtime_contracts_contain_execution_critical_metadata() -> Non
)["action_layouts"]
)
assert "mutated" not in vla_runtime_contract()["camera_order"]
+
+
+def test_behavior_uses_the_shared_pi05_registry_and_wire_contract() -> None:
+ from rpent.robots.components.pi05_vla_client import Pi05VLAClient
+ from rpent.robots.components.pi05_vla_server import (
+ PI05_EMBODIMENTS,
+ build_model_cfg,
+ )
+
+ assert PI05_EMBODIMENTS["behavior"]["openpi"]["config_name"] == "pi05_behavior"
+ assert PI05_EMBODIMENTS["behavior"]["openpi"]["action_chunk"] == 32
+ assert PI05_EMBODIMENTS["behavior"]["openpi"]["action_env_dim"] == 23
+ assert PI05_EMBODIMENTS["behavior"]["openpi_data"]["norm_stats_path"] == (
+ "assets/behavior-1k/2025-challenge-demos/norm_stats.json"
+ )
+ assert "assets" not in PI05_EMBODIMENTS["behavior"]["openpi_data"]
+ cfg = build_model_cfg("/tmp/pi05-behavior", PI05_EMBODIMENTS["behavior"])
+ assert cfg.openpi_data.norm_stats_path == (
+ "/tmp/pi05-behavior/assets/behavior-1k/2025-challenge-demos/norm_stats.json"
+ )
+
+ class FakeRpcClient:
+ def call(self, method, *, args, timeout_s):
+ assert method == "vla.predict"
+ observation, options = args
+ assert observation["main_images"].shape == (1, 720, 720, 3)
+ assert observation["wrist_images"].shape == (1, 2, 480, 480, 3)
+ assert observation["states"].shape == (1, 256)
+ assert observation["task_descriptions"] == ["turn on the radio"]
+ assert options == {"mode": "eval"}
+ assert timeout_s == 600.0
+ return np.zeros((1, 32, 23), dtype=np.float32)
+
+ model = Pi05VLAClient(FakeRpcClient(), embodiment="behavior")
+ action = model.predict(
+ {
+ "main_images": np.zeros((720, 720, 3), dtype=np.uint8),
+ "wrist_images": np.zeros((2, 480, 480, 3), dtype=np.uint8),
+ "states": np.zeros(256, dtype=np.float32),
+ "task_descriptions": "turn on the radio",
+ },
+ options={"mode": "eval"},
+ )
+ assert action.shape == (32, 23)
+ assert action.dtype == np.float32
+
+
+def test_behavior_resolves_the_official_challenge_instance_layout(
+ tmp_path: Path,
+) -> None:
+ from robots.behavior.rlinf_env import (
+ _bootstrap_template_path,
+ _resolve_activity_instance_dir,
+ )
+
+ dataset_root = tmp_path / "2025-challenge-task-instances"
+ json_dir = dataset_root / "scenes" / "house" / "json"
+ instance_dir = json_dir / "house_task_demo_instances"
+ instance_dir.mkdir(parents=True)
+ bootstrap = json_dir / "house_task_demo_0_0_template.json"
+ bootstrap.write_text("{}")
+ selected = instance_dir / "house_task_demo_0_242_template-tro_state.json"
+ selected.write_text("{}")
+
+ resolved = _resolve_activity_instance_dir(
+ dataset_root,
+ scene_model="house",
+ task_name="demo",
+ activity_definition_id=0,
+ activity_instance_id=242,
+ )
+
+ assert resolved == instance_dir
+ assert (
+ _bootstrap_template_path(
+ resolved,
+ scene_model="house",
+ task_name="demo",
+ activity_definition_id=0,
+ )
+ == bootstrap
+ )
+
+
+@pytest.mark.parametrize(
+ ("mode", "task_name", "public_seed", "role_title"),
+ [
+ ("eval", "turning_on_radio", 1, "ROLE AND EVALUATION"),
+ ("explore", "picking_up_trash", 0, "ROLE AND MODE"),
+ ],
+)
+def test_behavior_prompts_strictly_render_real_run_config(
+ tmp_path: Path,
+ mode: str,
+ task_name: str,
+ public_seed: int,
+ role_title: str,
+) -> None:
+ spec = get_robot_spec("behavior")
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--output-dir")
+ parser.add_argument("--memory-profile", choices=["hf", "local"], default=None)
+ parser.add_argument("--memory-dir")
+ spec.add_cli_args(parser, use_dashboard=False)
+ args = parser.parse_args(
+ [
+ "--task-name",
+ task_name,
+ "--public-seed",
+ str(public_seed),
+ "--behavior-mode",
+ mode,
+ "--output-dir",
+ str(tmp_path / "output"),
+ "--memory-dir",
+ str(tmp_path / "memory"),
+ ]
+ )
+ variables = spec.parse_config(args).prompt_vars
+
+ system = spec.prompts.render("system", variables=variables)
+ user = spec.prompts.render("user", variables=variables)
+
+ assert "{{" not in system
+ assert "{{" not in user
+ for value in (
+ task_name,
+ variables["task_instruction"],
+ str(public_seed),
+ mode,
+ variables["recipe_tag"],
+ variables["output_dir"],
+ str(variables["max_episode_steps"]),
+ str(variables["wall_clock_seconds"]),
+ str(variables["public_capabilities"]),
+ variables["memory_profile"],
+ variables["memory_dir"],
+ variables["memory_inbox"],
+ ):
+ assert value in system or value in user
+
+ ordered_sections = [
+ role_title,
+ "CURRENT INVOCATION",
+ "INVOCATION MODEL",
+ "RUNTIME",
+ "YOUR GOAL",
+ "MEMORY",
+ "EVIDENCE",
+ "PLANNER TOOLS",
+ "TERMINATION",
+ "OUTPUT DISCIPLINE",
+ ]
+ positions = [system.index(title) for title in ordered_sections]
+ assert positions == sorted(positions)
+ if mode == "explore":
+ assert f"{variables['output_dir']}/{variables['recipe_tag']}.json" in system
+ assert f"{variables['memory_inbox']}/wip/" in system
+ assert "Only after official success" in system
+ assert "Do not invoke merge yourself" in system
+ for tool in (
+ "pi0_nav_pick",
+ "observe",
+ "pixel_to_world",
+ "navigate_to",
+ "move_to",
+ "rotate_wrist",
+ "close",
+ "open",
+ "press",
+ ):
+ assert f"`{tool}`" in system
+ assert [user.index(title) for title in ("CELL", "MODE", "BEGIN")] == sorted(
+ user.index(title) for title in ("CELL", "MODE", "BEGIN")
+ )
+
+ required = {
+ "behavior_mode",
+ "task_name",
+ "task_instruction",
+ "public_seed",
+ "recipe_tag",
+ "output_dir",
+ "max_episode_steps",
+ "wall_clock_seconds",
+ "public_capabilities",
+ "memory_profile",
+ "memory_dir",
+ "memory_inbox",
+ }
+ for key in required:
+ incomplete = {name: value for name, value in variables.items() if name != key}
+ with pytest.raises(KeyError, match=key):
+ spec.prompts.render("system", variables=incomplete)
+
+ literal = {**variables, "task_instruction": "literal {{do_not_expand}}"}
+ assert "literal {{do_not_expand}}" in spec.prompts.render(
+ "system", variables=literal
+ )
+
+ with pytest.raises(ValueError, match="unsupported BEHAVIOR prompt mode"):
+ spec.prompts.render(
+ "system", variables={**variables, "behavior_mode": "unknown"}
+ )
diff --git a/tests/unit_tests/rpent/session/test_state_contracts.py b/tests/unit_tests/rpent/session/test_state_contracts.py
index 5b7316e55..09267937d 100644
--- a/tests/unit_tests/rpent/session/test_state_contracts.py
+++ b/tests/unit_tests/rpent/session/test_state_contracts.py
@@ -18,6 +18,7 @@
from pathlib import Path
from typing import Any
+import imageio.v2 as imageio
import numpy as np
import pytest
@@ -208,6 +209,56 @@ def test_env_state_run_level_artifact_round_trips(tmp_path: Path) -> None:
assert env_state.load_bytes("episode.mp4", step=None) == b"offline-mp4-bytes"
+def test_env_state_streams_video_artifact_without_frame_buffer(tmp_path: Path) -> None:
+ env_state = EnvState(tmp_path)
+ writer = env_state.open_video_writer(
+ "episode.mp4",
+ step=None,
+ fps=5,
+ max_frames=3,
+ )
+ frame = np.zeros((16, 16, 3), dtype=np.uint8)
+
+ assert not hasattr(writer, "_frames")
+ assert writer.append(frame) is True
+ assert writer.append(frame + 20) is True
+ assert writer.append(frame + 40) is True
+ assert writer.append(frame + 60) is False
+ assert writer.frames_written == 3
+ assert writer.frames_dropped == 1
+ assert writer.close() == "episode.mp4"
+ assert writer.close() == "episode.mp4"
+
+ path = env_state.artifact_path("episode.mp4", step=None)
+ assert path.stat().st_size > 0
+ reader = imageio.get_reader(path)
+ try:
+ frames = [np.asarray(item) for item in reader]
+ finally:
+ reader.close()
+ assert len(frames) == 3
+ assert json.loads((tmp_path / "states.json").read_text())["run_artifacts"] == [
+ "episode.mp4"
+ ]
+ assert list(tmp_path.glob(".*.tmp*.mp4")) == []
+
+
+def test_env_state_video_writer_abort_and_empty_close_do_not_publish(
+ tmp_path: Path,
+) -> None:
+ env_state = EnvState(tmp_path)
+ empty = env_state.open_video_writer("empty.mp4", step=None)
+ assert empty.close() is None
+ assert not env_state.exists("empty.mp4", step=None)
+
+ aborted = env_state.open_video_writer("aborted.mp4", step=None)
+ aborted.append(np.zeros((16, 16, 3), dtype=np.uint8))
+ aborted.abort()
+ aborted.abort()
+ assert not env_state.exists("aborted.mp4", step=None)
+ assert list(tmp_path.glob(".*.tmp*.mp4")) == []
+
+
def test_env_state_per_step_paths_save_load_and_exists(tmp_path: Path) -> None:
env_state = EnvState(tmp_path)
diff --git a/tests/unit_tests/rpent/tools/test_toolkit_contracts.py b/tests/unit_tests/rpent/tools/test_toolkit_contracts.py
index dee85228e..72e2e5071 100644
--- a/tests/unit_tests/rpent/tools/test_toolkit_contracts.py
+++ b/tests/unit_tests/rpent/tools/test_toolkit_contracts.py
@@ -93,9 +93,14 @@ def solved(self) -> bool:
def test_tool_result_builds_text_and_images_without_mutating_result() -> None:
image_payloads = {
"_image_bytes": b"main",
+ "_depth_image_bytes": b"main-depth",
"_image_cam_bytes": b"camera",
"_image_nav_bytes": b"navigation",
"_image_wrist_bytes": b"wrist",
+ "_image_left_wrist_bytes": b"left-wrist",
+ "_depth_left_wrist_bytes": b"left-wrist-depth",
+ "_image_right_wrist_bytes": b"right-wrist",
+ "_depth_right_wrist_bytes": b"right-wrist-depth",
}
result = {"status": "ok", "count": 2, **image_payloads}
original = copy.deepcopy(result)
@@ -115,6 +120,11 @@ def test_tool_result_builds_text_and_images_without_mutating_result() -> None:
"image",
"image",
"image",
+ "image",
+ "image",
+ "image",
+ "image",
+ "image",
]
assert [
base64.b64decode(block["source"]["data"])
diff --git a/tests/unit_tests/rpent/utils/test_daemon_lifecycle.py b/tests/unit_tests/rpent/utils/test_daemon_lifecycle.py
index 2d95f8080..85722d3b1 100644
--- a/tests/unit_tests/rpent/utils/test_daemon_lifecycle.py
+++ b/tests/unit_tests/rpent/utils/test_daemon_lifecycle.py
@@ -14,6 +14,7 @@
from __future__ import annotations
+import subprocess
import sys
import time
from collections.abc import Callable
@@ -122,3 +123,40 @@ def test_daemon_force_kills_after_terminate_timeout(tmp_path: Path) -> None:
_wait_until(lambda: daemon.poll() is not None)
finally:
daemon.stop(timeout=1.0)
+
+
+@pytest.mark.parametrize("close_stdin", [False, True])
+def test_parent_watch_does_not_abort_interpreter_shutdown(close_stdin: bool) -> None:
+ script = """
+import sys, threading, time
+from rpent.utils.daemon import watch_parent_death
+done = threading.Event()
+watch_parent_death(lambda: (print('eof', flush=True), done.set()))
+print('ready', flush=True)
+if CLOSE_STDIN:
+ assert done.wait(5)
+else:
+ time.sleep(0.1) # Leave the watcher blocked while the interpreter exits.
+""".replace("CLOSE_STDIN", repr(close_stdin))
+ proc = subprocess.Popen(
+ [sys.executable, "-c", script],
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ )
+ try:
+ assert proc.stdout.readline() == b"ready\n"
+ if close_stdin:
+ proc.stdin.write(b"not EOF yet")
+ proc.stdin.flush()
+ assert proc.poll() is None
+ proc.stdin.close()
+ assert proc.wait(timeout=10) == 0
+ assert proc.stdout.read() == (b"eof\n" if close_stdin else b"")
+ assert b"_enter_buffered_busy" not in proc.stderr.read()
+ finally:
+ if proc.poll() is None:
+ proc.kill()
+ proc.wait()
+ for stream in (proc.stdin, proc.stdout, proc.stderr):
+ stream.close()
|