Skip to content

Commit ba8ed44

Browse files
committed
agent_bilevel explorer: recover tool-validated capture, seed info-gain search
When the agent submits + simulator-validates a goal-reaching plan via evaluate_option_plan / refine_plan_sketch but ends with a prose summary whose final text doesn't parse into a sketch, the explorer previously dropped that productive solve to the random-options fallback (a wasted exploration attempt that could block train-driven early stopping). Enable the capture path (keyed to the explore task) and, on an empty parse, rebuild the sketch from the captured plan, grafting each option's continuous params onto SketchStep.initial_params so they seed the info-gain parameter search as candidates rather than being replayed verbatim. Mirrors the test solver's preference for the tool-validated capture over the final text; stale capture is cleared at entry so an exploration plan can't leak into a later test solve.
1 parent a6e9180 commit ba8ed44

2 files changed

Lines changed: 173 additions & 5 deletions

File tree

predicators/explorers/agent_bilevel_explorer.py

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import logging
1616
from typing import Any, Callable, Dict, List, Optional, Set
1717

18+
import numpy as np
1819
from gym.spaces import Box
1920

2021
from predicators import utils
@@ -71,12 +72,20 @@ def _get_exploration_strategy(self, train_task_idx: int,
7172
# task. Without this, the agent tunes/validates its exploration plan
7273
# against the wrong task (e.g. a test goal referencing objects this
7374
# task doesn't even have), so parameter search is meaningless and
74-
# only tasks solvable without tuning get solved. The explorer does
75-
# not use the approach's capture path, so disable it (and clear any
76-
# stale capture) to keep an exploration plan from leaking into the
77-
# next test solve.
75+
# only tasks solvable without tuning get solved.
76+
#
77+
# Enable the capture path too (keyed to current_task == this explore
78+
# task): the agent often submits + simulator-validates a goal-reaching
79+
# plan via evaluate_option_plan / refine_plan_sketch but then ends with
80+
# a prose summary whose final text doesn't parse into a sketch. Without
81+
# capture that productive solve is lost to the random-options fallback;
82+
# with it we recover the captured plan below (see _sketch_from_capture)
83+
# and feed its continuous params into the info-gain search. Clear any
84+
# stale capture first; the next test _solve re-points current_task and
85+
# clears capture again, so an exploration plan can't leak into a test
86+
# solve.
7887
self._tool_context.current_task = task
79-
self._tool_context.capture_goal_reaching_plans = False
88+
self._tool_context.capture_goal_reaching_plans = True
8089
self._tool_context.solved_plan = None
8190
self._tool_context.solved_sketch = None
8291

@@ -109,6 +118,16 @@ def _get_exploration_strategy(self, train_task_idx: int,
109118
parse_continuous_params=CFG.
110119
agent_bilevel_use_llm_initial_params,
111120
)
121+
if not sketch:
122+
# The agent's final message didn't parse into a sketch, but it
123+
# may have submitted + simulator-validated a goal-reaching plan
124+
# via evaluate_option_plan / refine_plan_sketch (captured into
125+
# solved_plan / solved_sketch). Recover that plan as the
126+
# sketch, carrying its continuous params as initial_params so
127+
# they seed the info-gain search below rather than being
128+
# replayed verbatim. This mirrors the test solver's preference
129+
# for the tool-validated capture over the final text.
130+
sketch = self._sketch_from_capture() or []
112131
if not sketch:
113132
raise ValueError("parsed empty plan sketch")
114133

@@ -244,6 +263,45 @@ def _get_exploration_strategy(self, train_task_idx: int,
244263
# Helpers
245264
# ------------------------------------------------------------------ #
246265

266+
def _sketch_from_capture(
267+
self) -> Optional[List[bilevel_sketch.SketchStep]]:
268+
"""Rebuild a sketch from a captured, tool-validated plan, or None.
269+
270+
``evaluate_option_plan`` / ``refine_plan_sketch`` stash a
271+
refined + forward-validated, goal-reaching plan on the explore
272+
task into ``solved_plan`` (grounded options with continuous
273+
params) and ``solved_sketch`` (the option skeleton plus the
274+
subgoals that actually held). We reconstruct a sketch from that
275+
skeleton and graft each captured option's continuous params onto
276+
the step's ``initial_params``, so the info-gain refinement below
277+
seeds them as the first candidate in each step's pool (see
278+
``_sample_info_seeking``) instead of replaying them verbatim.
279+
Consume (clear) the capture so it can't be reused.
280+
"""
281+
plan = self._tool_context.solved_plan
282+
captured_sketch = self._tool_context.solved_sketch
283+
self._tool_context.solved_plan = None
284+
self._tool_context.solved_sketch = None
285+
if not plan or not captured_sketch:
286+
return None
287+
seeded: List[bilevel_sketch.SketchStep] = []
288+
for i, step in enumerate(captured_sketch):
289+
params = None
290+
if i < len(plan):
291+
params = np.asarray(plan[i].params, dtype=np.float32)
292+
seeded.append(
293+
bilevel_sketch.SketchStep(
294+
option=step.option,
295+
objects=step.objects,
296+
subgoal_atoms=step.subgoal_atoms,
297+
subgoal_neg_atoms=step.subgoal_neg_atoms,
298+
initial_params=params))
299+
logging.info(
300+
"agent_bilevel explorer: final text didn't parse, recovered the "
301+
"agent's tool-validated plan from capture (%d steps); seeding its "
302+
"continuous params into the info-gain search.", len(seeded))
303+
return seeded
304+
247305
def _wrap_policy(
248306
self, policy: Callable[[State],
249307
Action]) -> Callable[[State], Action]:

tests/explorers/test_agent_bilevel_explorer.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from gym.spaces import Box
99

1010
from predicators import utils
11+
from predicators.agent_sdk.bilevel_sketch import SketchStep
1112
from predicators.agent_sdk.tools import ToolContext
1213
from predicators.explorers import create_explorer
1314
from predicators.explorers.agent_bilevel_explorer import AgentBilevelExplorer
@@ -302,6 +303,115 @@ def test_plan_truncates_at_deepest_subgoal_failure_after_backtracking():
302303
f"giving up, got {executed_names}")
303304

304305

306+
def _make_captured(pick_params, place_params):
307+
"""Build the (solved_plan, solved_sketch) a tool capture would stash."""
308+
grounded_plan = [
309+
_Pick.ground([_block0], np.array(pick_params, dtype=np.float32)),
310+
_Place.ground([_block0, _block1],
311+
np.array(place_params, dtype=np.float32)),
312+
]
313+
captured_sketch = [
314+
SketchStep(option=_Pick, objects=[_block0], subgoal_atoms=None),
315+
SketchStep(option=_Place,
316+
objects=[_block0, _block1],
317+
subgoal_atoms={GroundAtom(_On, [_block0, _block1])}),
318+
]
319+
return grounded_plan, captured_sketch
320+
321+
322+
def test_recovers_captured_plan_when_final_text_unparseable():
323+
"""Agent validates a plan via evaluate_option_plan but ends in prose:
324+
325+
explorer recovers the captured plan instead of falling back to
326+
random, and seeds the captured continuous params into refinement.
327+
"""
328+
_reset_config()
329+
330+
goal_state = _make_state({_block0: [0.5, 0.6, 0.0]})
331+
option_model = MagicMock()
332+
option_model.get_next_state_and_num_actions.return_value = (goal_state, 3)
333+
334+
pick_params, place_params = [0.42], [0.11, 0.22]
335+
grounded_plan, captured_sketch = _make_captured(pick_params, place_params)
336+
337+
explorer, tool_context = _make_explorer(option_model, None)
338+
339+
async def query_impl(_msg, **_kw):
340+
# Simulate the agent capturing a validated plan via the tool during
341+
# the query (set AFTER the explorer's entry-time capture clear), then
342+
# ending with prose that does NOT parse into a sketch.
343+
tool_context.solved_plan = grounded_plan
344+
tool_context.solved_sketch = captured_sketch
345+
return _assistant_response("Solved it. Plan: 1. pick 2. place. Done.")
346+
347+
explorer._agent_session.query = query_impl
348+
349+
policy, term_fn = explorer._get_exploration_strategy(0, timeout=5)
350+
351+
# Recovered (not random fallback): subgoals/options come from the capture.
352+
assert callable(policy)
353+
assert term_fn(_make_state()) is False
354+
assert tool_context.last_sketch_options == [
355+
("Pick", ["block0"]),
356+
("Place", ["block0", "block1"]),
357+
]
358+
# The capture was consumed (cleared) so it can't leak into a later solve.
359+
assert tool_context.solved_plan is None
360+
assert tool_context.solved_sketch is None
361+
# Captured params were seeded as initial_params: the option model is
362+
# invoked with them (Pick tries them first; Place's are pooled).
363+
called = [
364+
c.args[1]
365+
for c in option_model.get_next_state_and_num_actions.call_args_list
366+
]
367+
pick_calls = [o for o in called if o.name == "Pick"]
368+
place_calls = [o for o in called if o.name == "Place"]
369+
assert pick_calls and place_calls
370+
np.testing.assert_allclose(pick_calls[0].params, pick_params)
371+
assert any(
372+
np.allclose(o.params, place_params) for o in place_calls), \
373+
"captured Place params were not seeded into refinement"
374+
375+
376+
def test_captured_params_seed_info_gain_search():
377+
"""With info-seeking ON, the recovered capture's continuous params are
378+
seeded as candidates in the info-gain pool (not replayed verbatim)."""
379+
_reset_config(agent_explorer_info_seeking=True,
380+
agent_explorer_info_n_feasible_target=2,
381+
agent_bilevel_explorer_max_samples_per_step=4)
382+
383+
goal_state = _make_state({_block0: [0.5, 0.6, 0.0]})
384+
option_model = MagicMock()
385+
option_model.get_next_state_and_num_actions.return_value = (goal_state, 3)
386+
387+
place_params = [0.33, 0.44]
388+
grounded_plan, captured_sketch = _make_captured([0.42], place_params)
389+
390+
explorer, tool_context = _make_explorer(option_model, None)
391+
# Wire a trivial ensemble scorer so info-seeking engages on annotated
392+
# steps; constant score means the seeded candidate is chosen.
393+
tool_context.atom_disagreement_fn = lambda _s, _atoms: 0.0
394+
395+
async def query_impl(_msg, **_kw):
396+
tool_context.solved_plan = grounded_plan
397+
tool_context.solved_sketch = captured_sketch
398+
return _assistant_response("Done — summary only, no sketch block.")
399+
400+
explorer._agent_session.query = query_impl
401+
402+
policy, _ = explorer._get_exploration_strategy(0, timeout=5)
403+
assert callable(policy)
404+
# Place is the subgoal-annotated step that info-seeking pools; its captured
405+
# params must appear among the candidates the pool evaluated.
406+
place_calls = [
407+
c.args[1]
408+
for c in option_model.get_next_state_and_num_actions.call_args_list
409+
if c.args[1].name == "Place"
410+
]
411+
assert any(np.allclose(o.params, place_params) for o in place_calls), \
412+
"captured Place params were not seeded into the info-gain pool"
413+
414+
305415
def test_fallback_when_query_fails_and_flag_on():
306416
"""Agent raises → random options fallback when flag enabled."""
307417
_reset_config(agent_explorer_fallback_to_random=True)

0 commit comments

Comments
 (0)