Skip to content

Commit 7f62c18

Browse files
abrichrclaude
andauthored
refactor: make openadapt-ml a leaf; break ml<->evals import cycle (#65)
* refactor: make openadapt-ml a leaf; break ml<->evals cycle Phase 1 of the evals->ml refactor. Fork A: import the Benchmark* vocabulary (Task/Observation/Action/Agent) from openadapt-types instead of openadapt-evals. Removes the one hard, unconditional module-level ml->evals import in benchmarks/agent.py. Fork B: add a thin RolloutEnv Protocol (reset/step/observe/collect_rollout /screen_size) in training/grpo/rollout_env.py. The GRPO collector/trainer type against this interface; the concrete WAA env/adapters stay in openadapt-evals and implement it structurally. Also convert the remaining guarded module-level openadapt-evals imports (rollout_collector, verl_backend, trainer TaskConfig) to lazy in-function imports so openadapt-ml has zero module-level openadapt-evals imports. Adds openadapt-types as a dependency (>=0.2.0) with a local editable uv source until the openadapt-types release is published. Tests: tests/ (excl. integration) 451 passed, 5 skipped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKrVJJy5jWVCkXAqgUqtqZ * chore: pin openadapt-types>=0.3.0 (published w/ benchmark), drop local editable source * style: ruff format verl_backend.py (lazy-import refactor) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a21c454 commit 7f62c18

8 files changed

Lines changed: 132 additions & 65 deletions

File tree

openadapt_ml/benchmarks/agent.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,10 @@
3232
import re
3333
from typing import TYPE_CHECKING, Any
3434

35-
# Import base classes from openadapt-evals (canonical location)
36-
from openadapt_evals import (
35+
# Import base classes from openadapt-types (canonical schema package).
36+
# These used to live in openadapt-evals; importing them from the schema
37+
# package keeps openadapt-ml a leaf (no module-level ml -> evals import).
38+
from openadapt_types import (
3739
BenchmarkAction,
3840
BenchmarkAgent,
3941
BenchmarkObservation,

openadapt_ml/experiments/waa_demo/runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
)
3939

4040
if TYPE_CHECKING:
41-
from openadapt_evals import (
41+
from openadapt_types import (
4242
BenchmarkAction,
4343
BenchmarkObservation,
4444
BenchmarkTask,
@@ -305,7 +305,7 @@ def act(
305305
Returns:
306306
BenchmarkAction parsed from VLM response
307307
"""
308-
from openadapt_evals import BenchmarkAction
308+
from openadapt_types import BenchmarkAction
309309

310310
adapter = self._get_adapter()
311311

@@ -447,7 +447,7 @@ def _parse_response(
447447
Uses the same parsing logic as APIBenchmarkAgent.
448448
"""
449449
import re
450-
from openadapt_evals import BenchmarkAction
450+
from openadapt_types import BenchmarkAction
451451

452452
raw_action = {"response": response}
453453

openadapt_ml/training/grpo/rollout_collector.py

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,20 @@
1414
import logging
1515
import random
1616
from dataclasses import dataclass, field
17-
from typing import Any, Callable
17+
from typing import TYPE_CHECKING, Any, Callable
1818

1919
from openadapt_ml.training.grpo.config import GRPOConfig
2020
from openadapt_ml.training.grpo.reward import binary_task_success
21+
from openadapt_ml.training.grpo.rollout_env import RolloutEnv
2122

22-
logger = logging.getLogger(__name__)
23+
if TYPE_CHECKING: # pragma: no cover - typing only
24+
# The concrete environment/adapter live in openadapt-evals and implement
25+
# the RolloutEnv Protocol defined in openadapt-ml. They are imported
26+
# lazily (inside __init__) at runtime to keep openadapt-ml a leaf with no
27+
# module-level openadapt-evals import.
28+
from openadapt_evals.adapters import WAALiveAdapter
2329

24-
# Deferred imports for openadapt-evals dependencies (optional at install time)
25-
try:
26-
from openadapt_evals.adapters import (
27-
RLEnvironment,
28-
RolloutStep,
29-
WAALiveAdapter,
30-
WAALiveConfig,
31-
)
32-
from openadapt_evals.adapters.rl_env import ResetConfig
33-
except ImportError:
34-
RLEnvironment = None # type: ignore[assignment, misc]
35-
RolloutStep = None # type: ignore[assignment, misc]
36-
WAALiveAdapter = None # type: ignore[assignment, misc]
37-
WAALiveConfig = None # type: ignore[assignment, misc]
38-
ResetConfig = None # type: ignore[assignment, misc]
30+
logger = logging.getLogger(__name__)
3931

4032

4133
@dataclass
@@ -81,25 +73,35 @@ def __init__(
8173
config: GRPOConfig,
8274
task_configs: dict[str, Any] | None = None,
8375
) -> None:
84-
if RLEnvironment is None:
76+
# Lazy import: the concrete WAA adapter + RLEnvironment live in
77+
# openadapt-evals and implement the RolloutEnv Protocol. Importing
78+
# them here (not at module scope) keeps openadapt-ml a leaf.
79+
try:
80+
from openadapt_evals.adapters import (
81+
RLEnvironment,
82+
WAALiveAdapter,
83+
WAALiveConfig,
84+
)
85+
except ImportError as exc:
8586
raise ImportError(
8687
"openadapt-evals is required for rollout collection. "
8788
"Install it with: uv add openadapt-evals"
88-
)
89+
) from exc
8990

9091
self._config = config
9192
self._task_configs = task_configs or {}
92-
self._adapter = WAALiveAdapter(
93+
self._adapter: WAALiveAdapter = WAALiveAdapter(
9394
WAALiveConfig(
9495
server_url=config.server_url,
9596
evaluate_url=config.evaluate_url,
9697
)
9798
)
98-
self._env = RLEnvironment(self._adapter)
99+
# RLEnvironment structurally satisfies the RolloutEnv Protocol.
100+
self._env: RolloutEnv = RLEnvironment(self._adapter)
99101

100102
@property
101-
def env(self) -> Any:
102-
"""The underlying RLEnvironment instance."""
103+
def env(self) -> RolloutEnv:
104+
"""The underlying environment (implements the RolloutEnv Protocol)."""
103105
return self._env
104106

105107
def collect_group(
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Minimal environment interface driven by GRPO training.
2+
3+
RL training in ``openadapt-ml`` needs to *drive* an environment (reset it,
4+
step actions, observe, and collect whole rollouts), but the *concrete*
5+
environment is an evaluation-harness concern that lives in
6+
``openadapt-evals`` (e.g. ``RLEnvironment``, ``WAALiveAdapter``,
7+
``WAADesktopEnv``).
8+
9+
To keep ``openadapt-ml`` a dependency **leaf** (no module-level import of
10+
``openadapt-evals``), the trainer types against this thin ``RolloutEnv``
11+
Protocol instead of a concrete class. The concrete adapters in
12+
``openadapt-evals`` implement it structurally.
13+
14+
Only the surface the GRPO trainer/collector actually uses is declared here;
15+
signatures are intentionally loose (``Any``) so the concrete evals
16+
implementations conform structurally without importing evals-specific
17+
config types into ml.
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from typing import Any, Protocol, runtime_checkable
23+
24+
from openadapt_types import BenchmarkAction, BenchmarkObservation
25+
26+
27+
@runtime_checkable
28+
class RolloutEnv(Protocol):
29+
"""Structural interface for an environment the GRPO trainer can drive.
30+
31+
Implemented by ``openadapt_evals.adapters.rl_env.RLEnvironment`` (and
32+
compatible desktop environments). ml depends on this interface; evals
33+
provides the implementation.
34+
"""
35+
36+
@property
37+
def screen_size(self) -> tuple[int, int]:
38+
"""Current environment screen size as (width, height)."""
39+
...
40+
41+
def reset(self, config: Any = None) -> BenchmarkObservation:
42+
"""Reset the environment and return the initial observation."""
43+
...
44+
45+
def step(self, action: BenchmarkAction) -> Any:
46+
"""Execute an action, returning a step result (obs/reward/done/info)."""
47+
...
48+
49+
def observe(self) -> BenchmarkObservation:
50+
"""Return the current observation without stepping."""
51+
...
52+
53+
def collect_rollout(
54+
self,
55+
agent_fn: Any,
56+
max_steps: int = ...,
57+
stuck_window: int = ...,
58+
task_id: Any = None,
59+
) -> list[Any]:
60+
"""Run ``agent_fn`` to completion, returning the list of rollout steps."""
61+
...
62+
63+
64+
__all__ = ["RolloutEnv"]

openadapt_ml/training/grpo/trainer.py

Lines changed: 9 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,6 @@
5454
Rollout,
5555
)
5656

57-
# Optional import for TaskConfig (openadapt-evals may not be installed)
58-
try:
59-
from openadapt_evals.task_config import TaskConfig
60-
61-
_HAS_TASK_CONFIG = True
62-
except ImportError:
63-
TaskConfig = None # type: ignore[assignment, misc]
64-
_HAS_TASK_CONFIG = False
65-
6657
logger = logging.getLogger(__name__)
6758

6859
DEFAULT_SCREEN_SIZE: tuple[int, int] = (1920, 1080)
@@ -166,18 +157,7 @@ def _parse_vlm_output_to_action(
166157
167158
Supports: CLICK(x=0.XX, y=0.XX), TYPE(text="..."), WAIT(), DONE().
168159
"""
169-
try:
170-
from openadapt_evals.adapters.base import BenchmarkAction
171-
except ImportError:
172-
from dataclasses import dataclass as _dc
173-
174-
@_dc
175-
class BenchmarkAction: # type: ignore[no-redef]
176-
type: str = "done"
177-
x: float | None = None
178-
y: float | None = None
179-
text: str | None = None
180-
key: str | None = None
160+
from openadapt_types import BenchmarkAction
181161

182162
text = text.strip()
183163
width, height = screen_size
@@ -371,11 +351,16 @@ def _load_task_configs(self, task_dir: str) -> None:
371351
ImportError: If openadapt-evals is not installed.
372352
FileNotFoundError: If the directory does not exist.
373353
"""
374-
if not _HAS_TASK_CONFIG:
354+
# Lazy import: the concrete TaskConfig is an eval-harness concept.
355+
# Keeping it out of module scope keeps openadapt-ml a leaf (no
356+
# module-level openadapt-evals import).
357+
try:
358+
from openadapt_evals.task_config import TaskConfig
359+
except ImportError as exc:
375360
raise ImportError(
376361
"openadapt-evals is required for --task-dir support. "
377362
"Install with: pip install openadapt-evals"
378-
)
363+
) from exc
379364

380365
task_dir_path = Path(task_dir)
381366
if not task_dir_path.is_dir():
@@ -459,7 +444,7 @@ def _make_agent_fn(self) -> Callable:
459444
Captures model/processor by reference so weight updates during
460445
training are reflected in subsequent rollouts.
461446
"""
462-
from openadapt_evals.adapters.base import BenchmarkAction
447+
from openadapt_types import BenchmarkAction
463448

464449
model = self._model
465450
processor = self._processor

openadapt_ml/training/grpo/verl_backend.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,19 @@
3333

3434
logger = logging.getLogger(__name__)
3535

36-
# Deferred import for openadapt-evals WAADesktopEnv (optional dependency)
37-
try:
38-
from openadapt_evals.adapters.verl_env import WAADesktopEnv
39-
except ImportError:
40-
WAADesktopEnv = None # type: ignore[assignment, misc]
36+
37+
def _load_waa_desktop_env() -> Any | None:
38+
"""Lazily import the concrete WAADesktopEnv from openadapt-evals.
39+
40+
Kept out of module scope so openadapt-ml has no module-level
41+
openadapt-evals import (ml stays a dependency leaf). Returns the class,
42+
or ``None`` if openadapt-evals is not installed.
43+
"""
44+
try:
45+
from openadapt_evals.adapters.verl_env import WAADesktopEnv
46+
except ImportError:
47+
return None
48+
return WAADesktopEnv
4149

4250

4351
def build_vagen_config(config: GRPOConfig) -> dict[str, Any]:
@@ -96,7 +104,7 @@ def train_with_verl(config: GRPOConfig) -> None:
96104
"""
97105
vagen_config = build_vagen_config(config)
98106

99-
if WAADesktopEnv is not None:
107+
if _load_waa_desktop_env() is not None:
100108
logger.info(
101109
"WAADesktopEnv is available. verl-agent can use it for "
102110
"desktop environment interaction."

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ dependencies = [
2828
"matplotlib>=3.10.7",
2929
"modal>=1.3.4",
3030
"openadapt-capture>=0.3.0",
31+
# Canonical schema package. Benchmark* types (Task/Observation/Action/Agent)
32+
# live here so ml and evals don't import each other (breaks ml<->evals cycle).
33+
# NOTE: requires the openadapt-types release that adds openadapt_types.benchmark.
34+
"openadapt-types>=0.3.0",
3135
"pillow>=12.0.0",
3236
"pyautogui>=0.9.54",
3337
"pydantic-settings>=2.0.0",

tests/test_grpo.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -186,16 +186,18 @@ def test_rollout_defaults():
186186

187187
def test_rollout_collector_requires_evals():
188188
"""GRPORolloutCollector raises ImportError without openadapt-evals."""
189-
from openadapt_ml.training.grpo.rollout_collector import (
190-
GRPORolloutCollector,
191-
RLEnvironment,
192-
)
189+
import importlib.util
190+
191+
from openadapt_ml.training.grpo.rollout_collector import GRPORolloutCollector
193192
from openadapt_ml.training.grpo import GRPOConfig
194193

195-
# If openadapt-evals is not installed, this should raise ImportError.
196-
# If it IS installed, the constructor will try to connect (which we skip).
194+
# The concrete env/adapter are imported lazily inside __init__ (openadapt-ml
195+
# is a leaf and has no module-level openadapt-evals import). If evals is not
196+
# installed, constructing the collector should raise ImportError. If it IS
197+
# installed, the constructor will try to connect (which we skip).
197198
config = GRPOConfig(server_url="http://localhost:99999")
198-
if RLEnvironment is None:
199+
evals_installed = importlib.util.find_spec("openadapt_evals") is not None
200+
if not evals_installed:
199201
with pytest.raises(ImportError, match="openadapt-evals"):
200202
GRPORolloutCollector(config)
201203

0 commit comments

Comments
 (0)