AgentCanvas / Pages / Developer Guide / Nodesets / Env / Template (Gym Interface)
2026-07-04

The interface contract every interactive (MDP) env nodeset should implement so simulators expose one consistent surface. It keeps Gymnasium's four verbs — reset / step / close / observe — but adopts a pull-style perception model that fits VLN/EQA: step is a pure transition that returns no observation, and observe is the on-demand channel a method uses to fetch exactly the information it wants. Action and observation spaces are node-naming axes, not runtime config: each env exposes a step_<actionspace> family and an observe_<obsspace> family, all sharing fixed return contracts.

All five MDP env nodesets implement this contract (since 2026-06-10). Which env supports which spaces is registry information, not contract — see the capability matrix in the env catalog. The 2026-06 migration record (old→new node maps, pre-refactor audit) lives in this page's git history.

1. Interface taxonomy — where this template applies

env/ currently holds three interface types, separated by two axes: does the nodeset have an episode lifecycle, and does it have an action loop (do the agent's actions change what it observes next)? This template (§3–§5) specifies only the first type; the other two are listed so a new env nodeset gets classified deliberately instead of inheriting whatever shape its upstream code had.

TypeEpisode lifecycleAction loopVerb surfaceMembers
Interactive MDP (gym-like)yes — reset / evaluateclosed — step mutates world state; future observations changereset / step_* / observe_* / evaluate (+ suite-level close)env_habitat, env_mp3d, env_hmeqa, env_simpler, env_libero
Replay benchmark (scored dataset)yes — reset / episode_info / emit_metricsnone — data is pre-recorded; the agent's output feeds only the scorerreset / episode_info / sample_frames / parse_score / emit_metricsenv_openeqa_em
Stateless servicenonenone — pure request→responseper-capability tool verbs (locate_2d / locate_3d / segment)model_detany3d (lives in model/, listed here for taxonomy only)

Three boundary rules fall out of the table:

The two-axis matrix has one empty cell — action loop without episode lifecycle. That is the continuing / real-time environment (a physical robot, a live world): no reset (the world cannot be rewound), no episode boundary, and time does not pause between step and observe — which breaks gym's lockstep assumption. If the platform reaches real-robot deployment, that type arrives as a contract change, not as one more nodeset under this template. Other conceivable shapes — trajectory replay of recorded (obs, action) demonstrations for offline training, turn-based multi-agent — become relevant only if the platform's scope grows to cover them; no verb surface is reserved in advance.

2. Why, and why not vanilla gym

Env nodesets are pure tools (decoupled-nodeset principle): observe / step / episode-control surfaces, no method reasoning. Before this contract (pre-2026-06), each env had grown its own surface and they didn't line up — step returns differed per env, reward was exposed nowhere, done had no terminated/truncated split, and observe/step each went by 3–5 different names. The contract exists so a method written against one env finds the same verbs, the same port shapes, on every other env.

Vanilla gym fixes naming but assumes a model we don't have: gym observations are env-pushed and fixed-schema (declared by observation_space, returned every step), so they ride on step. Ours are agent-pulled and parametric — a method asks for a panorama (n_views=12), a single egocentric view, or the navigable-viewpoint graph, on demand, and panoramas are expensive enough that you must not render one every step. That is gym's render() role, not its observation. So we split transition from perception.

3. The model: four verbs, pull perception

VerbKindReturnsNotes
resetlifecycleepisode metadata only — no observationFirst frame is fetched by calling an observe node after reset
step_<x>transitioncontrol signals only — reward / terminated / truncated / infoA family, one node per action space x; no observation
observe_<y>perception (pull)the observation payload for obs space yA family, one node per observation space y; called on demand, idempotent
closelifecycleSuite-level, owned by env panel/manager — not a graph node. SIMPLER must never close per-episode (SAPIEN GC segfault)

A typical agent loop becomes: observe_* → reason → step_* → observe_* → …, with the loop's continue-condition reading terminated / truncated off the last step. The method pulls exactly the modalities it needs each iteration, and pays for expensive observations (panorama) only when it asks.

Episode selection (split / episode_index / play) stays env panel-owned — the gym gym.make(id) analog, not a node. A thin evaluate metric-sink node is kept outside the four verbs (decision E, §7); it fires once in the after-loop band (triggered by the iterOut's final_stop) and reads the env's terminal metrics.

4. Spaces as naming axes

Action and observation spaces are not graph nodes and not runtime config — they are a fixed vocabulary that names the step_* / observe_* node families and types their ports. Each env declares which spaces it supports and exposes the matching nodes. Cross-env unification = this vocabulary is shared and each suffix carries the same port contract everywhere, so a method that wants waypoint nav looks for *__step_waypoint and gets the same shape on every env that has it.

4.1 Action spaces → step_<x>

SpaceAction inputMeaning
discreteaction:ACTION (int 0–3)Fixed discrete set: FORWARD / LEFT / RIGHT / STOP
waypointviewpoint_id:TEXTNavigate to a navigable viewpoint id (discrete graph)
posetarget:POSENavigate / teleport to a target SE(3) pose
hightolowangle, distanceMacro (turn angle, advance distance) expanded to low-level actions
continuousaction:TEXT (JSON: 7-vec or list of 7-vecs)Continuous control delta (arm); accepts a single step or a runtime-variable K-step chunk. The wire-type registry has no VECTOR type — TEXT/JSON is the carrier (env_simpler, env_libero)
ee_poseaction:TEXT (JSON 8-vec [x,y,z,qw,qx,qy,qz,grip] or {"pose": […], "hold_steps": N})Drive the end-effector to an absolute world-frame pose + gripper via closed-loop OSC convergence (manipulation). Distinct from pose: carries a gripper bit + optional settle ticks, so it is not target:POSE-shaped. Added 2026-07-03 when env_libero__step_pose was renamed step_ee_pose — its shape never matched the nav pose row

4.2 Observation spaces → observe_<y>

SpaceOutput portsMeaning
egocentricrgb:IMAGE, depth:DEPTH, pose:POSE, intrinsics:ANYSingle first-person view; pose + intrinsics folded in here (no standalone localize/cam_intrinsics nodes)
panoramaviews:ANY, directions:TEXT, n_views:ANYMulti-view / stitched panorama (parametric, expensive)
navigableviewpoint_id:TEXT, navigable:ANY, headings:ANY, position:ANYCurrent viewpoint + reachable neighbors + headings (graph nav)
objectssnapshot:ANY, error:TEXTPrivileged GT scene snapshot — object names, point clouds, EE pose, bounds in one dict, so GT planners read one atomic state
framesframes:ANYEpisodic-memory frame sequence (non-MDP envs)

Reuse an existing suffix before inventing one — a new space name is a vocabulary change that every future env inherits. Which env implements which suffix is tracked in the capability matrix, not here.

5. Return contracts

5.1 reset

Begins an episode and emits episode-level metadata only (the gym info at episode start). These ports are env-specific because the metadata is — VLN carries instruction, EQA carries question — but the rule is fixed: no observation, no rgb/depth. The first frame comes from a following observe_* call.

Reset is an idempotent ensure-live: if the previous episode is done (a canvas re-run — reset fires once in the pre-loop band, before any observe), it re-arms an episode in place; a live episode — the batch-eval path, where the runner has just placed a fresh one — is read without disturbance. Episode placement (which episode) stays env panel-owned; reset never chooses, only revives (decision I, §7).

5.2 step_<x>

Input varies by action space (§4.1). Output is fixed across the whole family — every step_* on every env emits at least these four control ports:

PortTypeMeaning
rewardANYPer-step reward, scalar (VLN sparse → 0 each step, payoff at terminal; SIMPLER real)
terminatedBOOLMDP terminal: STOP called / goal reached / answer submitted
truncatedBOOLBudget / step-limit cutoff
infoANYPer-step diagnostics + terminal metrics (when terminated/truncated)

The wire-type registry (app/standard/wire_types.py) has no FLOAT or DICT type — ANY is the canonical carrier for scalars and plain dicts. So reward and info ride ANY; terminated/truncated use BOOL.

Env-specific extra outputs may sit alongside the four (mirrored inside info): the manipulation envs (env_simpler, env_libero) add success + step_index convenience taps, and env_libero__step_ee_pose adds converged / final_pose / step_count / error. The four-port contract is a floor, not a ceiling — agents wiring extras accept env coupling, exactly as with reset's env-specific metadata.

5.3 observe_<y>

Input is a bare trigger (perception is a read of current state, idempotent). Output ports are the obs-space payload from §4.2. No control signals — observe never advances the env, and performs no lifecycle action: observing a finished episode returns the terminal frame unchanged, never a fresh episode. Rollover ownership lives in reset (decision I, §7) — the ADR-012-era auto-reset inside observe_* was removed 2026-06-11 because a post-loop re-fire of an observe could silently roll the env into a new episode under evaluate.

5.4 close

Suite-level teardown owned by the env panel/manager, not a graph node. Per-episode close is forbidden for SIMPLER (SAPIEN 2.2.2 GC ordering segfault); mirror the upstream no-close pattern.

6. Checklist: what a new env nodeset provides

The contract above fixes the node surface; this is the fill-in-the-blanks list for shipping a new MDP env nodeset under it.

7. Decisions

#DecisionOutcome
AObservation port shapeResolved: obs is off step; pulled via per-obs-space observe_* nodes
BAction polymorphismResolved: one node per action space (step_* family), not a single polymorphic step
Cterminated / truncated splitAdopted: gymnasium two-bool split (STOP = terminated, budget = truncated)
DExpose rewardAdopted: yes, even where VLN reward is 0 every step
Eevaluate nodeAdopted, role updated 2026-06-11: a thin metric-sink reading the env's terminal state — wired in the after-loop band (iter_out.final_stop → evaluate.trigger), fired exactly once at termination. Per-step telemetry rides step.info → viewer; the verdict never comes from an in-loop evaluate
Fopeneqa_em exemptionResolved naturally: no action ⇒ no step node; it is reset + observe_frames
Greset returns obs?Decided: no — reset emits episode metadata only
Hpose / intrinsics nodesDecided: folded into observe_egocentric, no standalone nodes
Iepisode rollover ownershipDecided 2026-06-11: reset re-arms a done episode (idempotent ensure-live, §5.1); observe_* is a pure read. Replaces the ADR-012-era (2026-03-29) auto-reset inside habitat's observe nodes — a leftover from the commit that deleted explicit episode management before the env panel existed, and a metric-pollution hazard once evaluate moved to the after-loop band
AgentCanvas docs