Add bilevel_models primitive: expose the planner's models to the agent#115
Conversation
Extract the 'build SesameModels from an env's bilevel mapping' logic out of BilevelPlanningApproach into robocode.utils.bilevel.build_sesame_models, so the upcoming bilevel_models primitive can reuse it instead of duplicating the mapping read.
Expose the bilevel planning MODELS (predicates, operators, goal, and parameterized skills) to the coding agent as an env-bound 'bilevel_models' primitive: get_abstract_state / get_goal_atoms / operators / get_objects / run_skill. The agent composes these into a generalized program instead of running per-instance search; run_skill is documented as a simulator-backed convenience. Register it in the primitives factory and specs, and reject generated programs that reference the SeSamE planner (run_sesame etc.) at load.
Make the bilevel deps an optional 'bilevel' extra (lazy-imported so robocode.primitives still imports without it), and ship them to the sandbox ONLY when the bilevel_models primitive is requested: the kinder-baselines mount and 'uv sync --extra bilevel' are gated on 'bilevel_models' in primitive_names. Also move the human-facing primitive descriptions out of the sandbox-shipped primitive_specs into a host-only primitive_descriptions module (stripped from the mount), so a models-OFF agent cannot read the description of a primitive it was not granted. Adds a deterministic red-team probe battery verifying a models-OFF container has no path to the models (import, install, source, or description).
Adds a --models-off mode to the red-team harness: adversarial LLM prompts that try to import, find, install, or read the description of the bilevel planning models in a normal (no bilevel_models) sandbox, asserting none are reachable.
The render MCP server's _build_sandbox_primitives assumed every env-dependent primitive is a function named like the primitive (partial(getattr(module,name), env)), which crashed at boot for bilevel_models (a class BilevelModels), gating the agent (0 turns). Add ENV_DEPENDENT_ATTR mapping the primitive to its module attribute, and instantiate a class / partial a function so both check_action_ collision and bilevel_models build in-sandbox exactly as build_primitives does.
When bilevel_env_name is not set explicitly (e.g. a KinderGeom2DEnv the agent builds by hand to test its approach), infer the family and object-count kwargs from env_id so the bilevel_models primitive works without the explicit mapping. The env configs still set it explicitly (and win); a test checks inference agrees with every config so the two cannot silently diverge.
Three runtime-breaking gaps in the containerized non-agentic and black-box paths, found in review: - Keep primitive_descriptions.py in the genplan (keep_primitives) source mount. Its driver imports LLMGenPlanApproach, which imports the module at load time; stripping it crashed every containerized genplan/best-of-k run before training. The agent mount still strips it (unchanged). - Mount kinder-baselines and sync the bilevel extra for genplan when the config requests the bilevel_models primitive (Docker and Apptainer). The driver built the primitive but the models were not installed, so any policy using them failed on first use. - Reject bilevel_models in blackbox_primitive_manifest. It was advertised as a host_proxy with no BlackboxEnv method behind it (AttributeError). Full black-box support (serializing the symbolic layer) is deferred to a follow-up; fail loudly until then. Adds regression tests for each.
robocode.primitives re-exported PRIMITIVE_DESCRIPTIONS and format_primitives_description (as origin/main did and the module docstring promises); the descriptions split (b29b6c0) dropped them by mistake. Restore the re-exports and correct the now-stale docstring (descriptions moved to the host-only robocode.primitive_descriptions, not primitive_specs). Safe for the sandbox: the agent mount strips the primitives/ package so this __init__ never runs there, and the genplan mount keeps primitive_descriptions.py.
There was a problem hiding this comment.
Pull request overview
This PR introduces a new bilevel_models primitive that exposes SeSamE bilevel planner models (symbolic operators/predicates/skills + transition simulator) to agent code, while keeping the planner/search itself inaccessible. It also adds conditional sandbox mounting/syncing so bilevel sources/deps are only present when the primitive is granted, plus tests and red-team probes to validate “models OFF” isolation.
Changes:
- Add
BilevelModelsprimitive and a shared lazy model builder (build_sesame_models,infer_bilevel_mapping) used by both the primitive and the bilevel baseline. - Add anti-cheat load-time rejection of generated programs that reference planner symbols when
bilevel_modelsis present. - Add conditional Docker/Apptainer sandbox mounting and
uv sync --extra bilevelonly whenbilevel_modelsis requested, plus unit/integration tests (including models-OFF probes).
Reviewed changes
Copilot reviewed 27 out of 27 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/utils/test_episode.py | Adds unit tests for the anti-cheat load-time planner-reference rejection. |
| tests/utils/test_env_server.py | Ensures black-box primitive manifest rejects bilevel_models loudly. |
| tests/utils/test_docker_sandbox.py | Expands mount filtering to optionally include bilevel sources; adds models-OFF red-team container probe. |
| tests/utils/test_bilevel.py | Adds tests for shared bilevel model builder and env-id inference consistency with configs. |
| tests/utils/test_apptainer_sandbox.py | Validates Apptainer command composition for conditional bilevel mounting/syncing. |
| tests/primitives/test_bilevel_models.py | Adds primitive-level tests for laziness, symbolic layer access, and run_skill. |
| tests/mcp/test_local_render.py | Verifies in-sandbox primitive rebuilding supports both env-bound functions and env-bound classes. |
| tests/approaches/test_llm_genplan_approach.py | Ensures genplan container runs include bilevel extras only when bilevel_models is requested. |
| tests/approaches/test_bilevel_planning_approach.py | Updates missing-mapping test to an unsupported env case. |
| src/robocode/utils/episode.py | Adds anti-cheat check rejecting planner references when bilevel_models is present. |
| src/robocode/utils/docker_sandbox.py | Adds conditional copying/mounting of kinder-baselines and --extra bilevel syncing. |
| src/robocode/utils/bilevel.py | Introduces shared model construction and env-id→mapping inference. |
| src/robocode/utils/apptainer_sandbox.py | Adds conditional bilevel bind + extra syncing for Apptainer runs/genplan. |
| src/robocode/primitives/bilevel_models.py | Introduces the BilevelModels env-bound primitive (symbolic access + run_skill). |
| src/robocode/primitives/init.py | Registers bilevel_models and re-exports descriptions from host-only module. |
| src/robocode/primitive_specs.py | Adds bilevel_models metadata, env-dependent binding info, and black-box rejection. |
| src/robocode/primitive_descriptions.py | Splits human-facing primitive descriptions out of sandbox-safe metadata. |
| src/robocode/mcp/local_render.py | Updates sandbox primitive builder to handle env-dependent class primitives. |
| src/robocode/environments/kinder_geom2d_env.py | Adds env-id-based bilevel mapping inference fallback when mapping not provided. |
| src/robocode/approaches/llm_genplan_approach.py | Switches to host-only primitive descriptions import and forwards include_bilevel to container runner. |
| src/robocode/approaches/bilevel_planning_approach.py | Reuses shared build_sesame_models instead of duplicating builder logic. |
| src/robocode/approaches/agentic_cdl_approach.py | Switches to host-only primitive descriptions import. |
| src/robocode/approaches/agentic_base.py | Switches to host-only primitive descriptions import. |
| pyproject.toml | Moves bilevel deps into an optional bilevel extra. |
| integration_tests/red_team_sandbox.py | Adds “models OFF” adversarial prompts and detection logic. |
| docker/entrypoint.sh | Allows passing optional uv sync extra args via ROBOCODE_UV_EXTRA_ARGS. |
| docker/Dockerfile | Documents runtime-only bilevel extra install and optional kinder-baselines mount. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| `check_action_collision`): built from the environment's `bilevel_env_name` / | ||
| `bilevel_env_model_kwargs` mapping. | ||
|
|
||
| Design note: `run_skill` samples one set of continuous parameters and rolls the |
There was a problem hiding this comment.
interesting. can we change this so that run_skill doesn't internally sample, but instead, the continuous parameters need to be passed in? I don't think that sampling once makes a lot of sense
There was a problem hiding this comment.
that's a very good point, I had actually missed this entirely. I want to look more closely at this tomorrow. I guess a way to do it is expose the parameter space as part of the skill, so claude can decide them in whatever way it wants. We can also provide a sampling helper but I wouldn't to start
| " - `get_goal_atoms(obs) -> set[GroundAtom]` \u2014 the atoms the goal " | ||
| "requires.\n" | ||
| " - `operators` \u2014 the lifted operators (each has `.name`, " | ||
| "`.parameters`, `.preconditions`, `.add_effects`, `.delete_effects`); use " |
There was a problem hiding this comment.
do you mean the "use them to ..." part? or simplifying in general?
There was a problem hiding this comment.
Yeah I meant the "use them to" part!
run_skill now raises clear ValueErrors for an unknown skill_name (listing the valid skills) and for a wrong grounded-object count, instead of leaking a bare StopIteration to the agent. Adds tests for both paths. For the three Copilot suggestions we intentionally did not take (AST-based anti-cheat, assert->raise in build_sesame_models, tightening the red-team breach substrings), record short in-code rationale so the decision is on the record.
|
Fixed the easy copilot stuff but I want to have a look at the rest more carefully, today I was busy with emergency reviews for ARR so I will get to this tomorrow hopefully |
Drop run_skill and sample_skill_params: the BilevelModels primitive now exposes only the obs-based symbolic queries (get_abstract_state, get_goal_atoms, operators, skill_names, get_objects) plus the raw SesameModels bundle as .models. The agent drives skills itself via .models (controllers, transition_fn), reading the model source available in the sandbox. Rewrite the primitive description and nearby comments to be self-contained (no references to removed methods or to the review that produced them).
The agent mount copied all of src/robocode/approaches/, exposing the other baselines (e.g. bilevel_planning_approach.py, which drives skills through run_sesame) to the coding agent. Skip approaches/ in the agent mount and copy back only base_approach.py, which the in-sandbox MCP render server imports via robocode.utils.episode. The non-agentic genplan mount keeps all of approaches/ (its driver imports the genplan baselines).
# Conflicts: # integration_tests/red_team_sandbox.py # src/robocode/utils/docker_sandbox.py
|
Oh and another thing, I saw that at first the agent was just looking into our |
Interesting, is it still possible for the agent to do this? Maybe we should make it so that the baseline approach files are not copied into Docker? |
|
The agent's final report mentions "get_goal_atoms" and "get_abstract_state" as functions it looked for but did not exist--did it confuse itself, or do we have those mentioned in prompts somewhere? |
Commit 8757d00 should be taking care of this |
Good catch! These are from our |
Drop the BilevelModels wrapper class: primitives['bilevel_models'] is now the SesameModels bundle that build_sesame_models(env) returns, and the agent uses its fields directly (predicates, types, skills, operators, state_abstractor, goal_deriver, transition_fn, observation_to_state). - build_primitives builds only the requested primitives, so the models are built only when bilevel_models is granted (they need a mapped env). - The primitives dict holds a live dataclass, which OmegaConf rejects when merged into the approach config; run_experiment builds the approach as a Hydra partial and injects primitives at construction instead. - Rewrite the prompt description and the render-server binding for the raw bundle; update tests.


Summary
Adds the
bilevel_modelsprimitive: it exposes the PRPL bilevel planner's models (predicates, operators, parameterized skills, and the transition simulator) to the LLM coding agent, without giving it the planner itself. The agent composes these pieces into one frozen, generalized program instead of running per-instance search.This is the code behind the agentic bilevel experiments we went over in today's call — e.g. the obstruction2d models-ON result (agent program 90% vs. the SeSamE planner 33%) and the clutteredstorage2d hard-env analysis.
Stacked on the bilevel-planning per-instance baseline (#114).
What's in it
BilevelModelsprimitive (primitives/bilevel_models.py) — env-bound access to abstract state / goal atoms / objects / operators / skills, plusrun_skilland the models'transition_fnfor simulated previews. Lazy-constructed so it's cheap until used.utils/bilevel.py) — onebuild_sesame_modelsused by both the planner baseline and the primitive, plusinfer_bilevel_mappingso a plainKinderGeom2DEnvthe agent builds by hand still resolves its bilevel family.utils/episode.py) — whenbilevel_modelsis granted, a generatedapproach.pythat references the planner (run_sesame, backtracking refiner, planning graph) is rejected at load. The agent must use the models, not call the search.bilevelextra (lazy imported). A sandbox only mounts kinder-baselines anduv sync --extra bilevelwhen the primitive is granted; otherwise no bilevel source or packages reach the agent, and the prompt descriptions are stripped. This keeps the task structure the models encode out of runs that shouldn't see it.Deferred: black-box support (follow-up PR)
bilevel_modelsruns in clearbox/local mode only. In black-box mode it is rejected up front (loud error) rather than silently half-working.Supporting it in black-box is feasible but a standalone piece of work, and carries a design trade-off, so it's intentionally a follow-up:
str()the symbolic objects (for a in atoms,str(atom)), so the symbolic layer (Atom / Object / Type / Predicate / Operator) needs a real serialization into client-side objects, with continuous states kept as host tokens. Comparable in scope to thecrv_motion_planningremote-module path.bilevel_modelshands over the full symbolic decomposition. Exposing it in black-box relaxes what "black-box" means, so it should be a deliberate choice, not just plumbing.Testing
primitive_descriptions.py; genplan syncs the bilevel extra when requested; black-box manifest rejectsbilevel_models).mypy,pylint(10/10), and formatters clean.🤖 Generated with Claude Code