AgentCanvas / Pages / Developer Guide / Nodesets / Method / VoxPoser
2026-06-11

VoxPoser (Huang et al. 2023; repo huangwl18/VoxPoser) is a zero-shot manipulation method: a chain of Language Model Programs (LMPs) writes Python that composes 3-D value maps (affordance / avoidance / rotation / velocity / gripper) over a voxel grid, a greedy planner extracts a waypoint path, and the robot follows it. This page documents the decomposed, env-decoupled port — the 8-node local voxposer nodeset planning each subtask on a frozen GT scene snapshot and following waypoints closed-loop on LIBERO (Franka) via env_libero__step_ee_pose in voxposer_libero_decomposed.json. Upstream's RLBench env is replaced wholesale; this is a cross-simulator port, not a reproduction on the paper's benchmark.

At a glance
UpstreamVoxPoser (Huang et al. 2023) — repo huangwl18/VoxPoser pinned @ e3a4c9e (MIT). Core: src/interfaces.py (LMP_interface + execute()), src/LMP.py, src/planners.py, src/controllers.py, 8 prompt files (prompts/rlbench/), src/configs/rlbench_config.yaml.
Env consumedenv_liberoreset (metadata), observe_objects (privileged GT snapshot: BDDL object names, per-object point clouds, scene PC, EE pose, gripper, bounds), step_pose (8-vec or {pose, hold_steps}; OSC follow with convergence), evaluate
FM consumedNone via canvas wires — the LMPs call the LLM through the vendored LMP._cached_api_call, rerouted to litellm with a disk cache (~/.cache/agentcanvas/voxposer_llm/). Graph pins gpt-4o · temp 0 · max_tokens 512 (upstream default: gpt-4).
Method nodesetvoxposer (8 nodes, local-mode in the backend process) + vendored upstream modules (LMP.py, interfaces.py, planners.py, controllers.py, dynamics_models.py, utils.py, prompts/libero/) + port-added _runtime.py / _wired_env.py
StateModule-level _RUNTIMES[handle] cache (the per-episode 7-LMP runtime; handle issued by voxposer__init, rides wires as TEXT) + node-local cursor in dispense_waypoint; no graph_state containers
Graphworkspace/graphs/vla/unverified/voxposer_libero_decomposed.json (17 nodes, 31 edges; outer subtask scope step_budget 8 + inner follow loop inside the waypoint_exec composite, step_budget 80)
Fidelityfaithful with justified deviations + 1 unexplained latent divergence (inventory-first audit 2026-06-11 against e3a4c9e): 7/8 prompts and all planner/LMP configs byte-checked; plan/follow split fully recorded (commit a90cc7dc); headline: object-centric (pushing) subtasks lose the MPC controller path (§3E)
StatusPre-decoupling decomposed run: 3/5 success on libero_spatial task 7 (2026-06-03). GT-snapshot decoupling landed 2026-06-11 (a90cc7dc); post-decoupling smokes (1 ep, task 0) currently 0/1 — re-validation in progress (§4).

1. Upstream method analysis (Huang et al. 2023)

Upstream runs everything in-process against a live RLBench env. Per instruction:

  1. Planner LMP (prompts/rlbench/planner_prompt.txt): the instruction becomes Python that calls composer("<subtask>") once per subtask, in order. LMP config: gpt-4 · temp 0 · max_tokens 512 · stop ["# Query: ", "objects = "] (rlbench_config.yaml).
  2. Composer LMP per subtask (composer_prompt.txt, include_context: False): writes code that calls parse_query_obj (entity grounding) and the five get_*_map LMPs, then execute(movable, affordance_map=…, …).
  3. Value maps: each get_*_map LMP returns a callable evaluated lazily; unspecified maps fall back to current-state defaults (interfaces.py). Maps live on a map_size=100 voxel grid over the workspace bounds.
  4. Plan (execute() first half, interfaces.py:169–): object_centric = movable ∉ EE_ALIAS (:183); the greedy voxel planner (planners.py — gradient-free nearest-high-value descent with obstacle weighting, Savitzky–Golay smoothing, curvature cut) optimizes a path from the movable's position; postprocessing yields (position, rotation, velocity, gripper) waypoints.
  5. Follow (execute() second half): per waypoint, controller.execute(movable_obs, waypoint) (:162) — for the EE this reduces to a motion-planner move; for object-centric subtasks (pushing) it is a true MPC step: sample num_samples=10000 contact/push candidates through a heuristic PushingDynamicsModel, take the best, re-observe, repeat. After the loop, a final apply_action snaps to the last waypoint (:184–205, EE-centric only).
  6. Replan: max_plan_iter outer repetitions of plan→follow per subtask (config default 1 — effectively open-loop per subtask in sim).
  7. LLM caching: every API call goes through a pickle DiskCache keyed by the full request kwargs (LLM_cache.py) — re-runs are free.
RLBench (live env) instruction + scenelive object obs planner LMP→ composer("…") ×K subtask composer LMPcode per subtask parse_query_obj + 5 mapsvalue-map callables greedy voxel plannerplanners.py · savgol execute(): plan halfobject_centric :183 maps path DiskCache LLM_cache.py · pickle by kwargs every LMP call cached on disk controller.executeMP move / MPC push PushingDynamics MPC10000 samples · obj-centric one waypoint RLBench apply_actionEE pose + gripper inner: for waypoint in path — re-observe movable each step subtasks exhaustedcomposer calls done task successRLBench scoring outer: for subtask in plan — composer → maps → plan → follow simulator side planner / controller fn LMP (LLM-written code) loop guard disk cache data flow loops (inner / outer)

1.1 Upstream invariants worth marking

2. The AgentCanvas port

The port is a plan/follow split across a two-scope graph, with the planning stack fully env-decoupled (commit a90cc7dc, 2026-06-11). The vendored LMP stack runs local-mode in the backend process against a WiredEnv — a read-only env stand-in backed by a frozen GT snapshot from env_libero__observe_objects. This is sound because planning never steps the sim: every env read during one plan_subtask sees a single static state, so a snapshot is semantically identical to the live reads the retired in-subprocess adapter performed. Only two sim-driving entry points are stubbed in the composer's namespace: execute → LMP_interface.plan (plan, don't drive) and reset_to_default_pose → a single lift-to-home waypoint (z = 1.20). WiredEnv raises on any write call — the loud-failure guard motivated by the abandoned v2 design, which buffered execute() open-loop and scored SR 0.

2.1 Per-episode flow

env_libero (pre-loop) reset → observe_objectsGT snapshot (privileged) snapshot vp_init7-LMP runtime + WiredEnv handle vp_plannercomposer capture-stub subtask list iter_in_outerinit: handle·list·budget obs_loopfresh snapshot per iter vp_plan_subtaskcomposer+maps+plan expand_for_settle60-tick holds at flips 8-vec traj _RUNTIMES[handle] 7 LMPs + WiredEnv, backend process litellm + disk cache inside LMP waypoint_exec (composite — inner follow loop, step_budget 80) dispense_waypointcursor over trajectory plan_executor→ step_pose JSON env_libero step_poseOSC follow · hold_steps expanded trajectory inner: next waypoint until cursor exhausted or success vp_check_donesuccess · all subtasks · budget episode_success · step_index iter_out_outer · stopdone → ends outer loop done evaluateenv_libero · success final_stop (post-loop) outer: iterOut → iterIn — subtask_index · episode_success · step_index persist env_libero node voxposer node LMP-driven node (LLM inside) loop pivot runtime cache data wire state access loops (inner / outer)

Drawn simplified in three places. ① vp_planner and vp_plan_subtask are violet-amber hybrids: the LLM call happens inside the vendored LMP stack (litellm + disk cache), not on a canvas wire — there is no llmCall node in this graph. ② The outer pivot pair carries subtask_index / episode_success / step_index (persist) plus init ports for runtime_handle / subtask_list / subtask_count / max_steps; obs_loop re-fires every outer iteration (triggered by iter_in_outer.init_subtask_list) so each subtask plans against a fresh snapshot. ③ Termination: vp_check_done.done → iter_out_outer.stop (success, all-subtasks-complete, or env step budget), and post-loop iter_out_outer.final_stop → evaluate.trigger — the sound pivot-rooted shape. The inner follow loop lives inside the waypoint_exec composite (its own iterIn/iterOut, budget 80) and drives every waypoint closed-loop through step_pose; hold_steps > 0 entries tell the env to skip OSC convergence and run N zero-delta physics ticks with the new gripper command.

2.2 Node inventory

NodeInOutRole
voxposer__initsnapshotruntime_handleOne-shot per episode: builds WiredEnv + all 7 LMPs (setup_LMP, the vendored path) and caches the runtime under an opaque handle. Config carries the LLM knobs (graph: gpt-4o · 0 · 512 · load_cache on) and planner/controller dims (map_size 100 etc.).
voxposer__planner_lmpruntime_handle, instructionsubtask_list, subtask_count, errorRuns the planner LMP with composer swapped for a capture stub — subtask strings are recorded, not executed (upstream runs them inline).
voxposer__plan_subtaskruntime_handle, subtask_list, subtask_index, snapshottrajectory, n_waypoints, subtask_text, next_subtask_index, movable_name, object_centric, prev_gripper, errorOuter-scope body: refresh WiredEnv from the fresh snapshot, run the composer with execute → plan / reset → lift-waypoint stubs, serialize (pos, rot, vel, grip) waypoints to 8-vecs (velocity dropped — recorded). Multiple execute calls per subtask concatenate.
voxposer__expand_for_settletrajectory, prev_gripper?expanded, n_in/n_out/n_holds_insertedPort-added: detect gripper-bit flips, split each into drive-with-old-grip + flip-and-hold (settle_steps=60) — MuJoCo needs physics ticks for fingers to actually close. Cross-subtask grip continuity via a node-local last_out_grip carry.
voxposer__dispense_waypoint (inner)trajectorywaypoint, cursor, done, progressCursor over the expanded list; identity-hash reset on a new trajectory (= new outer iter).
voxposer__plan_executor (inner)waypointaction (JSON), has_actionFormat one waypoint for step_pose: legacy 8-vec list or {pose, hold_steps} object.
voxposer__check_waypoint_done (inner)cursor_done, episode_successdone, reasonInner termination: trajectory exhausted or early success.
voxposer__check_donesubtask_index/count, episode_success, step_index, max_stepsdone, reasonOuter termination: success / all subtasks complete / env step budget.

2.3 State & memory

StateWhere · lifetimeWriterUpstream counterpart
_RUNTIMES[handle]module dict, backend process · until nodeset shutdownvoxposer__initThe in-process LMP_interface + LMP objects
WiredEnv snapshot fieldsinside the runtime · refreshed per outer iterplan_subtask (via update_snapshot)Live RLBench observations
ctx.cursor / ctx.last_trajectory_identdispense_waypoint node-localitselffor waypoint in path loop variable
ctx.last_out_gripexpand_for_settle node-localitselfimplicit in upstream's continuous control stream
LLM disk cache~/.cache/agentcanvas/voxposer_llm/ · cross-runvendored LMP._cached_api_callDiskCache (same key shape: post-rewrite kwargs)

2.4 Boundary contract

Method side runs local-mode (no server_python); LIBERO stays in its env subprocess. The only env coupling is the read side — env_libero__observe_objects, one privileged GT snapshot dict (BDDL object names, per-object AABB point clouds, scene PC, EE pose, gripper, workspace bounds) — and the write side, env_libero__step_ee_pose waypoint actions. Any env exposing that observe/step pair can host VoxPoser. Boundary deviation worth knowing: the LLM is not consumed via a foundation-model nodeset — the vendored LMP._cached_api_call calls litellm directly (recorded in the LMP.py header: cache-shape preservation, retries delegated, chat-only). The TODO #56 method/FM split therefore does not apply to this nodeset's LLM usage.

2.5 Prompt assets

workspace/nodesets/method/voxposer/prompts/libero/ — 8 files matching upstream's prompts/rlbench/ names. Byte-diff this audit: 7 of 8 are byte-identical to the RLBench originals (modulo trailing newline) — planner, composer, and all five get_*_map prompts carry no LIBERO adaptation at all. Only parse_query_obj_prompt.txt was rewritten: the few-shot examples now use LIBERO BDDL object vocabulary (alphabet_soup_1, akita_black_bowl_1, …) so entity grounding sees in-domain names. LMP configs (model / 512 / temp 0 / stop / include_context: False for the composer / has_return for the map LMPs) reproduce rlbench_config.yaml.

3. Delta vs upstream — four buckets

A. Preserved verbatim

ElementUpstream anchorHow preserved
7 of 8 prompt files (planner, composer, 5 maps)prompts/rlbench/*.txtByte-identical mod trailing newline (diffed 2026-06-11)
LMP machinery (exec namespace, context assembly, has_return, stop tokens, cache-key shape)src/LMP.pyVendored; only the API transport rewritten (header-documented, see C)
Greedy voxel planner (obstacle weighting, savgol smoothing, curvature cut, all 12 config keys)src/planners.py + yaml planner:Byte-identical mod relative imports; config dict reproduced key-for-key in _runtime.build_general_config
Controller + pushing dynamics codesrc/controllers.py, src/dynamics_models.pyVendored verbatim (dynamics byte-identical) — but see §3E for the unused call path
LMP configs (gpt-4 default · 512 · temp 0 · include_context per LMP · map_size 100 · num_waypoints_per_plan 10000 · max_plan_iter 1)src/configs/rlbench_config.yaml_lmp_cfg / build_general_config reproduce every key
Disk-cache semantics (key = post-rewrite kwargs incl. messages)src/LLM_cache.pyVendored mod whitespace; cache dir relocated (C)
Value-map default fallbacks (unspecified map → current-state callable)interfaces.pyFactored into _fill_default_voxel_maps, shared by execute() and plan() so both branch identically (inline-documented)

B. Forced by substrate (script → typed graph)

UpstreamPortWhy forced
One in-process call stack (planner → composer → execute)8 nodes over a two-scope graph: outer subtask iteration + inner waypoint follow loop in a compositeEach LLM/planning stage must be a node for AAS search granularity; loops must be pivot pairs
Planner LMP executes composer calls inlinerun_planner swaps composer for a capture stub; subtask strings cross a wire as LIST[TEXT]The graph owns subtask sequencing (outer scope), not the generated code
Live env handle inside LMP_interfaceWiredEnv backed by a per-iter GT snapshot wire; write calls raiseMethod runs in the backend process; the sim lives in a subprocess — only data can cross (decoupling commit a90cc7dc)
Loop variables (waypoint index, subtask index)dispense_waypoint cursor (node-local ctx) + next_subtask_index latched through iter_out_outerCross-iteration values must ride pivots or node-local state
Heavy runtime object passed by reference_RUNTIMES[handle] + TEXT handle on wiresThe 7-LMP runtime can't serialize onto wires
from utils import …from .utils import … (all vendored files)Package-relative imports required for vendoring

C. Forced by environment / cost

UpstreamPortReason
RLBench + CoppeliaSim, live object detection from sim APILIBERO (MuJoCo, Franka) + privileged BDDL GT snapshotCross-simulator port; LIBERO is the project's manipulation env (paper #1 executor cell)
parse_query_obj few-shots with RLBench-style namesRewritten with LIBERO BDDL vocabulary (the one non-verbatim prompt)Entity grounding must see in-domain object names
openai.ChatCompletion + retry loop, legacy completion endpointlitellm via get_llm_config() profiles; retries delegated; chat-only; cache under ~/.cache/agentcanvas/voxposer_llm/Provider-pluggable infra; recorded in the LMP.py vendoring header
Model gpt-4 (2023)Graph config pins gpt-4o (temp 0 · 512 unchanged)Era-equivalent successor; explicit in vp_init config in the graph JSON

D. Intentional divergences

UpstreamPortRationale (recorded where)
execute() plans and drives the sim per subtaskplan_subtask plans only (composer's execute → LMP_interface.plan); the graph follows waypoints closed-loop via step_poseModule docstrings + commit a90cc7dc; the abandoned v2 buffered execute() open-loop instead — SR 0; LMP-exec ↔ sim-stepping interleave can't cross a wire
Plan-time gripper state read live from the robotWiredEnv pins plan-time gripper OPEN; snapshot["gripper_open"] deliberately unconsumedFrozen-v1 parity, documented at _wired_env.py:61–72: the verified adapter's gripper sync was a silent no-op (key never emitted), so plans were always made open-gripper; a truthful read (closed after LIBERO's reset settle) inverts every default gripper_map and breaks grasps — smoke 20260610_105613 (grip_seq CCC…oOOO, bowl pushed not grasped)
Continuous control stream gives fingers time to close implicitlyexpand_for_settle: two-waypoint split at each gripper flip + 60 hold_steps (zero-delta ticks)Node description: restores the validated v1 fat-node _GRIPPER_TRANSITION_SETTLE behaviour at the graph layer; open-loop dispatch alone never closes the fingers
reset_to_default_pose() drives the arm homeStub emits one lift-to-z=1.20 waypoint_runtime.py: mirrors the retired adapter's _DEFAULT_HOME_Z; above plate/object tops, inside workspace bounds
Waypoints carry velocityVelocity dropped from the 8-vec_serialize_traj docstring: the step_pose OSC follow has its own speed bound
assert + pdb.set_trace() on bad observation callablesraise TypeErrorVendored utils.py: a debugger breakpoint can't fire in a backend process (defensive cleanup; lazy plotly import likewise)

E. Unexplained / defects

#FindingUpstreamPortSeverity
E1Object-centric (pushing) subtasks lose the MPC controllerFor object_centric subtasks the planned waypoints are the object's path, followed by a true MPC step per waypoint — 10000 sampled pushes through PushingDynamicsModel, re-observing each step (interfaces.py:162, :183)plan() computes the same object-path waypoints and the same object_centric flag, but the follow loop sends every waypoint to step_pose as an EE target; the controller is constructed yet never executed, and the graph consumes object_centric nowhere. The plan/follow split is recorded; this specific loss inside it is not.Latent–Moderate — invisible on libero_spatial pick-place (movable = EE ⇒ object_centric=False throughout), wrong for any pushing-class instruction: the EE would trace the object's intended path instead of pushing the object along it. Surface as a TODO candidate if pushing tasks enter the suite.

Probed and cleared: stop-token nuance ("objects =" vs upstream planner's "objects = " — the no-space form is a prefix, so generation stops at the same boundary); all 12 planner config keys byte-equal to yaml; dynamics_models.py byte-identical; _fill_default_voxel_maps refactor branch-equivalent to upstream's inline defaults; empty-path EE fallback (plan() emits one current-pose waypoint where upstream relied on the in-execute final apply_action — inline-documented); the LIBERO single-execute-per-subtask idiom (multiple execute calls concatenate, documented in plan_subtask). No equivalence test exists for this nodeset — the v1-monolith ground truth was deleted with the monolithic graph (per user decision, commit a90cc7dc), so eval parity (§4) is the only executable check.

4. Evaluation

WhatNumberEvidence
Paper-reportednot comparableUpstream reports RLBench + real-robot tasks; no LIBERO numbers exist upstream — there is no paper-claim closure target for this port
Decomposed, pre-decoupling (in-subprocess adapter era)3/5 successlibero_spatial task 7, 2026-06-03 (recorded in the gym-migration memory; graphs then lived in vla/verified/)
Monolithic-era AAS baselinessee run dirsoutputs/design_runs/{myloop,aflow,adas-subagent}/voxposer_libero_v4/ — historical dirs keep the old v4 name; that graph was deleted 2026-06-10, numbers do not transfer to this page's graph
Post-decoupling smokes (2026-06-11)0/1 (task 0, 1 ep)runs 20260611_134223, 20260611_152007 (5 outer steps, ~1390 env ticks) — GT-snapshot re-validation in progress, same-day as the decoupling commit

Current status: voxposer_libero_decomposed.json sits in workspace/graphs/vla/unverified/ — moved 2026-06-10 with the env_libero gym migration + decoupling, pending a clean re-validation run. The 3/5 reference predates the snapshot transport; the decoupling argument (frozen snapshot ≡ live reads, because planning never steps the sim) says behaviour should be unchanged, but that claim is exactly what the pending re-eval must confirm. Note the LLM disk cache when interpreting re-runs: with load_cache=true and temp 0, repeated runs replay cached plans — a change in outcome between cached runs implicates the follow/env side, not the LMPs.

5. Usage

# load — method is local-mode; env is server-mode
POST /api/components/nodesets/voxposer/load
POST /api/components/nodesets/env_libero/load?mode=server

# graph
workspace/graphs/vla/unverified/voxposer_libero_decomposed.json

# batch eval — graph-only form: /experiment:run <profile> <graph_name> [key=value ...]
/experiment:run smoke_voxposer_libero_decomposed voxposer_libero_decomposed episode_count=1
/experiment:run perf_voxposer_libero_decomposed voxposer_libero_decomposed

6. What this nodeset is NOT

7. Source files

FilePurpose
workspace/nodesets/method/voxposer/__init__.pyThe 8 canvas nodes (675 lines)
workspace/nodesets/method/voxposer/_runtime.pyPort-added: episode runtime — 7-LMP build, capture-stubbed planner, plan-stubbed composer (351 lines)
workspace/nodesets/method/voxposer/_wired_env.pyPort-added: snapshot-backed read-only env (incl. the gripper-OPEN pin, :61–72)
workspace/nodesets/method/voxposer/{LMP,interfaces,planners,controllers,dynamics_models,LLM_cache,utils}.pyVendored upstream modules (deltas in §3)
workspace/nodesets/method/voxposer/prompts/libero/8 prompt files — 7 byte-identical to upstream rlbench, parse_query_obj re-voiced for BDDL names
workspace/nodesets/env/env_libero/Env side — observe_objects snapshot, step_pose OSC follow + hold_steps
workspace/graphs/vla/unverified/voxposer_libero_decomposed.jsonReference agent graph (this page's §2.1 authority; inner loop in the waypoint_exec composite)
workspace/nodesets/_upstream/voxposer/fetch_upstream.shClones upstream @ e3a4c9e (MIT) into a gitignored upstream/
outputs/design_runs/*/voxposer_libero_v4/Historical AAS runs on the deleted monolithic graph (old naming, immutable)
AgentCanvas docs