diff --git a/src/inspect_robots/cli.py b/src/inspect_robots/cli.py index 383719d..7a1611a 100644 --- a/src/inspect_robots/cli.py +++ b/src/inspect_robots/cli.py @@ -71,6 +71,7 @@ from inspect_robots.rollout import TrialRecord from inspect_robots.scene import Scene from inspect_robots.spaces import Box + from inspect_robots.task import Task def _styled(text: str, code: str) -> str: @@ -478,6 +479,23 @@ def _resolve_or_exit( ) from exc +def _apply_epochs_or_exit(task: Task, epochs: int) -> Task: + """Apply ``--epochs`` with a guided error instead of a raw traceback (#145). + + ``replace()`` reruns ``Task.__post_init__``, which rejects a count below 1 + via ``ConfigError`` — the same validation-error class ``_resolve_or_exit`` + already converts to ``SystemExit`` for registry resolution. + """ + from dataclasses import replace + + from inspect_robots.errors import ConfigError + + try: + return replace(task, epochs=epochs) + except ConfigError as exc: + raise SystemExit(str(exc)) from exc + + def _build_guardrails( space: Box, max_action_delta: float | None ) -> tuple[Approver, list[str], list[str]]: @@ -835,8 +853,6 @@ def _build_and_announce_guardrails(args: argparse.Namespace, action_space: Box) def _cmd_run(args: argparse.Namespace) -> int: - from dataclasses import replace - from inspect_robots import eval from inspect_robots.logging import JsonLogSink from inspect_robots.scene import Scene @@ -885,7 +901,7 @@ def _cmd_run(args: argparse.Namespace) -> int: embodiment = resolved.embodiment try: if args.epochs is not None: - task = replace(task, epochs=args.epochs) + task = _apply_epochs_or_exit(task, args.epochs) _announce_components(resolved) approver = _build_and_announce_guardrails(args, embodiment.info.action_space) @@ -995,8 +1011,6 @@ def _cmd_eval_set(args: argparse.Namespace) -> int: the embodiment once per task), the CLI resolves the embodiment exactly once for the whole set, so a real robot is not reconnected between tasks. """ - from dataclasses import replace - from inspect_robots import eval_set _check_shared_run_conflicts(args) @@ -1005,7 +1019,7 @@ def _cmd_eval_set(args: argparse.Namespace) -> int: defaults = load_defaults(os.environ) tasks = [_resolve_or_exit("task", name) for name in task_names] if args.epochs is not None: - tasks = [replace(t, epochs=args.epochs) for t in tasks] + tasks = [_apply_epochs_or_exit(t, args.epochs) for t in tasks] resolved = _resolve_components(args, defaults) embodiment = resolved.embodiment diff --git a/tests/test_registry_cli.py b/tests/test_registry_cli.py index 0e09a14..67f997e 100644 --- a/tests/test_registry_cli.py +++ b/tests/test_registry_cli.py @@ -539,6 +539,29 @@ def test_cli_eval_set_sim_and_embodiment_conflict() -> None: main(["eval-set", "cubepick-reach", "--sim", "--embodiment", "cubepick"]) +def test_cli_eval_set_negative_epochs_is_a_guided_error(tmp_path: Path) -> None: + """--epochs -1 must not leak Task.__post_init__'s raw ConfigError (#145).""" + _register_task("kb/a") + try: + with pytest.raises(SystemExit, match="Epochs count must be >= 1, got -1"): + main( + [ + "eval-set", + "kb/a", + "--policy", + "scripted", + "--embodiment", + "cubepick", + "--epochs", + "-1", + "--log-dir", + str(tmp_path), + ] + ) + finally: + reg._FACTORIES["task"].pop("kb/a", None) + + def test_cli_eval_set_guardrail_flags_conflict() -> None: with pytest.raises(SystemExit, match="drop one"): main( @@ -2462,10 +2485,9 @@ def close(self) -> None: def test_cli_run_closes_embodiment_when_validation_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """A failure between resolving the embodiment and eval() (here: --epochs 0 - raising ConfigError) must still close the embodiment — otherwise a bad flag - leaves real arms energized.""" - from inspect_robots.errors import ConfigError + """A failure between resolving the embodiment and eval() (here: --epochs 0, + a guided SystemExit per #145) must still close the embodiment — otherwise a + bad flag leaves real arms energized.""" from inspect_robots.mock import CubePickEmbodiment closed: list[bool] = [] @@ -2478,7 +2500,7 @@ def close(self) -> None: monkeypatch.setitem(reg._FACTORIES["embodiment"], "tracked-cubepick", _Tracked) monkeypatch.setenv(ENV_POLICY, "scripted") monkeypatch.setenv(ENV_EMBODIMENT, "tracked-cubepick") - with pytest.raises(ConfigError): + with pytest.raises(SystemExit, match="Epochs count must be >= 1"): main( [ "reach the cube",