Run frontier LLMs (GPT, Claude, Gemini, anything behind an OpenAI-compatible API) as closed-loop control policies on the LIBERO-10 manipulation benchmark.
This repo is a plugin for Inspect Robots,
built the same way as the plugins in that repo (entry points, EmbodimentBase,
Task, -E/-T arguments). It adds two registry entries:
| Registry kind | Name | What it is |
|---|---|---|
| embodiment | libero |
A LIBERO Panda arm in MuJoCo, 20 Hz, absolute end-effector pose actions, agentview + wrist RGB |
| task | libero_10 |
One LIBERO-10 task, one scene per LIBERO initial state, scored by LIBERO's own success predicate |
The LLM side is the upstream agent policy from inspect-robots-agent,
used unchanged. The model sees the camera images and robot state, calls tools
such as move_to, and the upstream framework turns each call into actions.
This repo also adds a runner for paid experiments with hard USD spending caps, and optional few-shot demonstration storyboards.
- Installation
- API keys
- Check the installation
- LIBERO-10 tasks
- What the model is told
- Run one LIBERO-10 rollout
- Use a different LLM
- Paid runs with spending caps
- Cost pilot and HTML report
- Few-shot demonstrations
- Python API
- Implementation notes
- Repository layout
Tested on Linux with an NVIDIA GPU (headless EGL rendering). You need git
and conda. Everything, LIBERO and the LLM agent stack, goes into one
Python 3.12 environment.
Inspect Robots is installed from source at a pinned commit (upstream_commit.txt):
git clone https://github.com/IRVLUTD/LLM_LIBERO.git
cd LLM_LIBERO
git clone https://github.com/robocurve/inspect-robots.git vendor/inspect-robots
git -C vendor/inspect-robots checkout "$(cat upstream_commit.txt)"conda create -n llm-libero python=3.12 -y
conda activate llm-libero
pip install hf-libero==0.1.4 imageio imageio-ffmpeg h5py
pip install -r requirements.lock.txt \
./vendor/inspect-robots \
./vendor/inspect-robots/plugins/inspect-robots-agent
pip install -e .hf-libero is Hugging Face's packaging of LIBERO. It installs robosuite 1.4.0,
MuJoCo 3.x and bddl 1.0.1, and downloads the scene assets (about 400 MB) from
the Hugging Face Hub the first time a scene loads.
The first import asks
Do you want to specify a custom path for the dataset folder? (Y/N). Answer N:
python -c "import libero.libero"This writes ~/.libero/config.yaml. The simulator refuses to start without it,
since the question would otherwise block it.
cp .env.example .env
chmod 600 .envAdd your API keys (next section).
All commands below assume the llm-libero environment is active.
Keys live only in .env in the repo root. .gitignore excludes it
(and any .env.* backup), and only .env.example is tracked:
# .env
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
# GEMINI_API_KEY=...Fill in only the providers you use. The variable names are the ones
inspect-robots-agent looks for (see Use a different LLM).
Run commands from the repo root so .env is found. Variables already set in
your shell take precedence.
Warning
Never commit .env. Before your first push, run
git status --ignored to confirm .env is listed as ignored. If a key is
ever committed, revoke it at the provider. Removing it from git history
is not enough.
None of these checks calls a real LLM or needs an API key.
# 1. Unit tests: task factory, registry wiring, budget accounting (mocked HTTP)
python -m unittest discover -s tests -v
# 2. The plugin is discovered by the upstream registry
inspect-robots list embodiments # includes: libero
inspect-robots list tasks # includes: libero_10
# 3. Simulator only: lift 3 cm, close and open the gripper, check LIBERO tracked it
python scripts/check_adapter.py
# 4. Full closed loop with a mocked LLM (one run per provider wire)
python scripts/check_closed_loop.py --model openai/gpt-6-astra
python scripts/check_closed_loop.py --model anthropic/claude-opus-5-5
python scripts/check_closed_loop.py --model google/gemini-4-argonChecks 3 and 4 each print a JSON line with "passed": true. Their outputs go
to outputs/checks/.
task_id is LIBERO's native order (benchmark.get_benchmark_dict()["libero_10"]()):
task_id |
Scene | Instruction |
|---|---|---|
| 0 | LIVING_ROOM_SCENE2 | put both the alphabet soup and the tomato sauce in the basket |
| 1 | LIVING_ROOM_SCENE2 | put both the cream cheese box and the butter in the basket |
| 2 | KITCHEN_SCENE3 | turn on the stove and put the moka pot on it |
| 3 | KITCHEN_SCENE4 | put the black bowl in the bottom drawer of the cabinet and close it |
| 4 | LIVING_ROOM_SCENE5 | put the white mug on the left plate and put the yellow and white mug on the right plate |
| 5 | STUDY_SCENE1 | pick up the book and place it in the back compartment of the caddy |
| 6 | LIVING_ROOM_SCENE6 | put the white mug on the plate and put the chocolate pudding to the right of the plate |
| 7 | LIVING_ROOM_SCENE1 | put both the alphabet soup and the cream cheese box in the basket |
| 8 | KITCHEN_SCENE8 | put both moka pots on the stove |
| 9 | KITCHEN_SCENE6 | put the yellow and white mug in the microwave and close it |
Each task has 50 fixed initial states (0 to 49). Scene i loads initial state
i with LIBERO env seed 142 + i, lets the scene settle for 10 no-op steps,
then hands control to the policy. The horizon is 900 steps (45 s at 20 Hz).
By default the model gets no task-specific help. Its system prompt has two parts:
- The upstream
agentpolicy's generic prompt (_SYSTEM_TEMPLATEininspect_robots_agent/policy.py): one tool call per turn, anotewith every move, calldoneorgive_up, and the LLM call budget. Embodiment notes:followed byBASE_DOCSfromsrc/inspect_robots_libero/embodiment.py, quoted in full below.
BASE_DOCS only covers how this robot works and general manipulation
practice that applies to every task, such as "avoid collisions". It never
names an object, a task, or a strategy for one task. The user message then
gives the instruction (Goal: ...), and each observation gives the robot
state and the two camera images.
Single Panda arm. move_to uses world-frame x,y,z in meters and rot6d
orientation (first two rotation-matrix columns). z points up. Gripper 0=open,
1=close. The gripper is open/close only: any value above 0.5 keeps closing and
any value below 0.5 keeps opening, so intermediate values do not give a
partial opening. The gripper entry of eef_state is the current gripper
command, not the finger position: it reads 1 while closed on an object even
though the fingers are stopped apart. Measured finger positions are in
gripper_qpos (meters, about +/-0.04 each when fully open, near 0 when closed
on nothing); fingers stopped clearly apart after closing indicate something is
between them. Omitting gripper from a move_to call keeps the current command.
Keep orientation unchanged unless necessary. Images are upright agentview and
wrist camera views. Only robot proprioception and RGB are available; no object
coordinates. Intermediate target arrival must be checked from the next
observation. Verify the object actually followed the lift in the next
observation before assuming the grasp is secure.
General manipulation guidance:
1. Avoid collisions: before a lateral move that could intersect nearby objects
or furniture, lift to a visibly safe clearance. Do not sweep through or
push objects unless the task requires it. Use large moves in free space and
small moves only in the last few centimeters near an object.
2. Identify the target object carefully; similar-looking distractors may be
present. Cross-check both camera views.
3. A closed gripper does not mean a successful grasp. After closing, lift 3-5
cm and confirm in both cameras that the object rose with the gripper.
4. Handle objects the way a person would: grasp a stable, graspable part
without pushing or tipping the object, keep it in the same orientation
(usually upright) from grasp to placement, and lower it close to the
support surface before releasing.
5. If the same approach fails twice, change it (grasp point, approach
direction, or a small adjustment); do not repeat the same motion.
6. After each move, confirm from the next observation that it succeeded before
starting the next step; do not assume success.
7. Prefer fewer, purposeful moves over many tiny corrections; your call budget
is limited.
8. Declare success only when the latest observation clearly shows that the
requested final state has been achieved.
tests/test_readme.py fails if this block and BASE_DOCS drift apart.
Runs made before commit fab7acb used an earlier wording of the gripper
sentences in the first paragraph (see git log -p src/inspect_robots_libero/embodiment.py).
Nothing else is added unless you ask for it. Task notes and demonstration
storyboards are off by default (see Few-shot demonstrations),
and protocol.json records "zero_shot": true when neither was used.
This is the standard Inspect Robots CLI. One rollout of task 2, initial state 0, with Claude:
inspect-robots run \
--task libero_10 -T task_id=2 \
--embodiment libero \
--policy agent -P model=anthropic/claude-opus-5-5 -P wire=messages -P effort=low \
--grader none --no-prompt \
--log-dir outputs/logsWhat each part does:
| Flag | Meaning |
|---|---|
--task libero_10 -T task_id=2 |
LIBERO-10 task 2. Other -T options are listed below the table |
--embodiment libero |
The LIBERO simulator. It takes the task from the scene |
--policy agent -P model=... |
The upstream LLM policy. See Use a different LLM |
-P effort=low |
Reasoning effort, forwarded to the provider |
--grader none --no-prompt |
Success comes from LIBERO's predicate, so skip the human grader |
libero_10 task options (-T), with defaults:
| Option | Default | Meaning |
|---|---|---|
task_id |
0 |
LIBERO-10 task, 0 to 9 (see LIBERO-10 tasks) |
start |
0 |
First initial state to run, 0 to 49 |
num_scenes |
1 |
Number of consecutive initial states, one episode each. -T start=10 -T num_scenes=5 runs initial states 10 to 14. start + num_scenes must not exceed 50 |
max_steps |
900 |
Step limit per episode (45 s at 20 Hz). -T max_steps=600 gives a 30 s horizon |
Useful agent options (all from inspect-robots-agent): -P max_llm_calls=20
caps LLM requests per episode, -P image_horizon=2 keeps images only for the
last two observations, and -P images=on_demand sends images only when the
model calls take_pic.
Useful libero options: -E output_dir=outputs/libero sets where videos go.
-E task_notes=true -E task_id=2 adds demonstration-derived hints to the
prompt (see Few-shot demonstrations).
Outputs of the run above:
outputs/logs/libero-10-task02_<id>.json eval log: metrics, actions, LLM usage
outputs/logs/transcripts/, outputs/logs/wire/ agent conversation and raw API traffic
outputs/libero/<time>_task02_init00/rollout.mp4 agentview | wrist video, 20 fps
outputs/libero/worker.log simulator stderr
Inspect them with the upstream tools:
inspect-robots inspect outputs/logs/<log>.json --transcript # what the model saw and said
inspect-robots view outputs/logs --serve # HTML viewerLIBERO's success predicate is checked after every control step. As soon as it
becomes true, the episode ends as a success (success_at_end = 1), even in the
middle of a move_to call. When the model calls done, the episode ends, but
that does not count as success by itself.
Model ids follow the upstream provider/model convention. The policy picks
the endpoint from the prefix and the key from the matching variable in .env:
| Provider | Example -P model= |
Key in .env |
Suggested -P wire= |
|---|---|---|---|
| OpenAI | openai/gpt-6-sol |
OPENAI_API_KEY |
responses |
| Anthropic | anthropic/claude-opus-5-5 |
ANTHROPIC_API_KEY |
messages |
| Google Gemini | google/gemini-4-argon |
GEMINI_API_KEY |
chat (default) |
Example:
inspect-robots run --task libero_10 -T task_id=0 --embodiment libero \
--policy agent -P model=openai/gpt-6-sol -P wire=responses -P effort=medium \
--grader none --no-prompt --log-dir outputs/logsThis repo was tested with openai/gpt-6-astra, openai/gpt-6-luna,
openai/gpt-6-sol, openai/gpt-6.1-sol, anthropic/claude-opus-5,
anthropic/claude-opus-5-5, and google/gemini-4-argon.
inspect-robots run has no dollar limit. For experiments that spend real
money, use scripts/run_budgeted_rollouts.py. It runs the same task,
embodiment and policy as the CLI, with these additions:
- Budgets are fail-closed. Before each request it reserves a conservative cost estimate, and it refuses to send if the episode or total budget would be exceeded.
- A request whose cost cannot be confirmed (network error, missing usage) blocks all later requests in the run.
- Requests are only sent to the chosen model's own provider endpoint.
- Every request and response body is saved to
api/for auditing, with a per-call cost inapi_usage.jsonl. - Each episode gets a fresh policy, so no memory carries over between episodes.
- It writes
protocol.json(everything needed to reproduce the run) andsummary.json(one result row per episode).
python scripts/run_budgeted_rollouts.py \
--model anthropic/claude-opus-5-5 \
--task-id 2 --episodes 5 --start 0 \
--total-budget 10 --episode-budget 3 --max-calls 20 --effort low \
--output outputs/task02_opus_zeroshot \
--execute-paid-rolloutsWithout --execute-paid-rollouts it sends nothing. --output must be a new
directory, so a paid run is never resumed and billed twice. Results:
outputs/task02_opus_zeroshot/
protocol.json summary.json
api/api_usage.jsonl, api/ep00_call01_request.json, ...
episode_00/inspect/<log>.json episode_00/<time>_task02_init00/rollout.mp4
summary.json reports two costs, per episode and in total:
| Field | Meaning |
|---|---|
estimated_usd, total_estimated_usd |
Spend estimated from the reported token usage, with cache reads and writes at their own rates. Close to the provider's bill |
budget_charge_usd, total_budget_charge_usd |
What the budget caps count. All input is charged at the cache-write rate, so this is higher than the real spend, often several times higher when most input is cached |
Only models with a price row are accepted: openai/gpt-6-astra,
openai/gpt-6-luna, openai/gpt-6-sol, openai/gpt-6.1-sol,
anthropic/claude-opus-5, anthropic/claude-opus-5-5,
google/gemini-4-argon. To add
one, put its per-million-token prices in PRICING in
src/inspect_robots_libero/budget.py. A new provider also needs entries in
PROVIDER_WIRE and WIRE_ENDPOINTS. The provider's own billing is the final
word. The local numbers are conservative estimates.
Compare GPT-6 Luna, Sol, and Astra at low, medium, and high reasoning on the
same LIBERO task and initial state. The default sweep has nine configurations;
GPT-6.1 Sol is also available through --config. Each configuration gets a full episode that
runs until task success, model termination, the simulator horizon, the high
emergency call ceiling, or its own dollar limit. Each run has a separate cap
inside a sweep-wide cap. Unknown API usage stops the entire pilot.
python scripts/run_cost_pilot.py \
--task-id 0 --init-state 0 --max-calls 100 --max-steps 900 \
--max-output-tokens 8192 --per-run-budget 5 --total-budget 45 \
--output outputs/my-cost-pilot --execute-paid-rolloutsUse repeated --config flags to run a subset in a chosen order. Available IDs
include luna_high, sol_low, astra_medium, and gpt_6_1_sol_high.
If selecting all 12 configurations with $5 per run, set --total-budget 60.
View the published 12-configuration cost report. Its GPT-6.1 Sol high attempt was interrupted after 27 calls, so its cost is partial. The raw run files stay local and are excluded from Git.
The output directory must be new. The command sends paid requests using
OPENAI_API_KEY from .env or the shell. It writes report.html after each
configuration, plus pilot.json, report_data.json, comparison.csv, API
usage records, simulator videos, trajectories, and framework logs. Open
report.html directly in a browser: its interactive charts and videos are
embedded and it works offline. To rebuild the report without API calls:
python scripts/run_cost_pilot.py --output outputs/my-cost-pilot --report-onlyEach run records the repository commit, pinned Inspect Robots commit, complete embodiment prompt, and its SHA-256 fingerprint so prompt changes remain visible. The report shows measured API costs, conservative budget charges, latency, input/cached/reasoning/output tokens, output truncations, tool commands, conversation history, end-effector/gripper trajectories, videos, initial-state hash comparisons, and adjustable longer-episode cost projections. Costs use returned usage and the published rates recorded in the protocol; they exclude local compute and electricity. Provider billing remains authoritative.
The transport reserves the full enforced output-token limit before each call. Its input-token reservation is conservative, not an exact provider tokenizer. OpenAI requests are restricted to a conservative short-context bound so the configured short-context prices apply. Budget reservations may stop a run before the measured bill reaches the allowance.
This is one full episode per configuration, not a success-rate benchmark. The
policy knows the high emergency call ceiling, but normal runs end on task
success, model termination, the simulator horizon, or its per-run dollar limit. Later
calls may have longer histories or different cache hits; linear cost
projections are illustrative. Truncated or unknown-usage runs have no projection. Increasing
--max-output-tokens gives reasoning more room but increases each reservation.
By default runs are zero-shot. The model gets the instruction, the live
images and robot state, and nothing else. Two optional additions give it
prior knowledge. protocol.json records whether either was used
("zero_shot": false).
Demonstration storyboards: 12 frames from each of a few human demos in the official LIBERO-10 dataset, sent as images before the first observation. The dataset is about 1.3 GB per task (13 GB for all ten), so download only the task you need:
# Official LIBERO-10 demos for task 2 (KITCHEN_SCENE3)
pip install "huggingface_hub[cli]"
huggingface-cli download yifengzhu-hf/LIBERO-datasets \
--repo-type dataset --include "libero_10/KITCHEN_SCENE3_*" --local-dir data/libero
# Storyboards for 2 random demos of task 2
python scripts/prepare_demonstrations.py \
--task-id 2 --num-demos 2 --seed 42 --dataset-dir data/libero/libero_10
# -> outputs/demos/task02_seed42_n2/manifest.json
python scripts/run_budgeted_rollouts.py --model openai/gpt-6-sol --task-id 2 \
--demo-manifest outputs/demos/task02_seed42_n2/manifest.json \
--total-budget 5 --output outputs/task02_sol_2demo --execute-paid-rolloutsTask notes: short text hints written by watching one demo. Only tasks 2 and 3
have them, in src/inspect_robots_libero/notes.py. Enable them with
--task-notes in the paid runner, or -E task_notes=true -E task_id=N with the CLI.
The same run as the CLI example, written as a script. Run it from the repo root
so .env is found.
import os
from inspect_robots import eval
from inspect_robots.approver import ChainApprover, ClampApprover, DeltaLimitApprover
from inspect_robots.defaults import init_dotenv
from inspect_robots_agent.policy import agent_policy
from inspect_robots_libero import libero_10, libero_embodiment
# Load API keys from ./.env into os.environ. The CLI does this for you; eval() does not.
# Variables already set in your shell take precedence over the file.
init_dotenv(os.environ)
task = libero_10(task_id=2, num_scenes=3) # initial states 0, 1, 2
embodiment = libero_embodiment(output_dir="outputs/libero")
# No key argument here: agent_policy reads ANTHROPIC_API_KEY from os.environ,
# chosen by the "anthropic/" prefix of the model id.
policy = agent_policy(model="anthropic/claude-opus-5-5", wire="messages", effort="low")
space = embodiment.info.action_space
try:
(log,) = eval(task, policy, embodiment, log_dir="outputs/logs",
# Guardrails: the CLI adds these by default, eval() only if you pass them.
approver=ChainApprover(ClampApprover(space), DeltaLimitApprover(space)))
print(log.results.metrics) # {'success_at_end': ...}
finally:
embodiment.close() # stop the simulator subprocessTwo differences from the CLI:
- API keys.
eval()does not read.env. Callinit_dotenv(os.environ)at the top of the script, beforeagent_policy, as above. - Guardrails. Without
approver=, actions go to the arm unchecked. Keep theChainApprover(ClampApprover(space), DeltaLimitApprover(space))line.
Like inspect-robots run, this has no dollar limit. Use
scripts/run_budgeted_rollouts.py for paid experiments.
- Action space: 10-D absolute end-effector pose in the world frame,
x, y, z(m) plus rot6d (the first two columns of the rotation matrix) plus a gripper command in [0, 1] (0 open, 1 closed). robosuite's Panda gripper follows only the sign of2 * gripper - 1, so the command acts as open/close, not as a finger position. The upstream agent's Cartesian tools only support rot6d or no rotation, not axis-angle. The worker orthonormalises rot6d, converts it to axis-angle, and drives LIBERO's OSC controller withuse_delta=False. - Observations:
agentview(flipped on both axes so it is upright, as in the standard LIBERO client) andwrist, both 256x256. Stateeef_stateis the controller's ownee_posplusee_ori_matin rot6d plus the gripper's current command state, so observed and commanded poses share one frame. The agent interpolates eachmove_tofromeef_state; reporting measured finger closure there instead would start a grasp-holding move below 0.5 and open the hand.robot0_eef_quatuses a different reference frame, so it is not used.gripper_qposis the raw finger positions. Object poses and the success predicate are never shown to the model. - Operating notes for the model are
BASE_DOCS, passed asEmbodimentInfo.docs. See What the model is told. - Success:
success_at_end, from LIBERO'scheck_success(). The episode also ends as soon as the predicate becomes true.
src/inspect_robots_libero/
__init__.py entry-point factories: libero_embodiment, libero_10
embodiment.py LiberoEmbodiment (EmbodimentBase), talks to the worker over stdio
_worker.py simulator subprocess: LIBERO env, action conversion, video
tasks.py libero_10 task, task table, seeds
budget.py BudgetTransport: spending caps, pricing, audit logging
pilot_report.py standalone interactive HTML report generator
demos.py demonstration storyboards as a prompt message
notes.py optional per-task hints (not zero-shot)
scripts/
run_budgeted_rollouts.py paid runs with caps
run_cost_pilot.py nine-run GPT-6 cost comparison
prepare_demonstrations.py storyboards from LIBERO HDF5
check_adapter.py simulator-only check
check_closed_loop.py closed loop with a mocked LLM
tests/ unit tests (python -m unittest)
upstream_commit.txt pinned inspect-robots commit
requirements.lock.txt pinned third-party deps of the agent stack
Built on Inspect Robots and LIBERO (Hugging Face packaging).